first
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import {
|
||||
originMiddleware,
|
||||
originValidationMiddleware,
|
||||
userMiddleware,
|
||||
bodyParser,
|
||||
signinRateLimiter,
|
||||
signupRateLimiter,
|
||||
forgotPasswordRateLimiter,
|
||||
} from '../../_middlewares';
|
||||
import { signinHandler } from './signin';
|
||||
import { signoutHandler } from './signout';
|
||||
import { signupHandler } from './signup';
|
||||
import { verifyHandler } from './verify';
|
||||
import { verifyTokenHandler } from './verify-token';
|
||||
import { resendVerificationHandler } from './resend-verification';
|
||||
import { changePasswordHandler } from './change-password';
|
||||
import { forgotPasswordHandler } from './forgot-password';
|
||||
import { resetPasswordHandler } from './reset-password';
|
||||
import { usersMe } from './users-me';
|
||||
import { passkeyRouter } from './passkey-router';
|
||||
|
||||
export const authRouter = createRouter();
|
||||
|
||||
authRouter.use(bodyParser());
|
||||
authRouter.use(originMiddleware);
|
||||
authRouter.use(originValidationMiddleware);
|
||||
|
||||
authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
|
||||
|
||||
authRouter.get('/me', userMiddleware, usersMe);
|
||||
authRouter.post('/signin', signinRateLimiter, signinHandler);
|
||||
authRouter.post('/signout', userMiddleware, signoutHandler);
|
||||
authRouter.post('/signup', signupRateLimiter, signupHandler);
|
||||
authRouter.post('/verify', verifyHandler);
|
||||
authRouter.post('/verify-token', verifyTokenHandler);
|
||||
authRouter.post('/resend-verification', resendVerificationHandler);
|
||||
authRouter.post('/change-password', userMiddleware, changePasswordHandler);
|
||||
authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler);
|
||||
authRouter.post('/reset-password', resetPasswordHandler);
|
||||
authRouter.route('/passkeys', passkeyRouter);
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import argon2 from 'argon2';
|
||||
import { sign } from '@@/jwt';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
|
||||
const { PUBLIC_BUILD_ENV } = process.env;
|
||||
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
|
||||
|
||||
export const changePasswordHandler: Handler = async function (ctx) {
|
||||
const { password, newPassword } = ctx.get('body');
|
||||
if (isProduction) validatePassword(newPassword);
|
||||
const reqUser = ctx.get('user');
|
||||
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.id, reqUser.id),
|
||||
columns: { password: true },
|
||||
});
|
||||
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
const isCorrectOldPassword = await argon2.verify(dbUser.password!, password);
|
||||
if (!isCorrectOldPassword) {
|
||||
throw errors.createError(403, 'Incorrect old password');
|
||||
}
|
||||
|
||||
const newPasswordHash = await argon2.hash(newPassword);
|
||||
// Use floored seconds-to-ms so the token iat (also floored) is never behind
|
||||
const passwordChangedAt = Math.floor(Date.now() / 1000) * 1000;
|
||||
await officerdb.update(Users).set({ password: newPasswordHash, passwordChangedAt }).where(eq(Users.id, reqUser.id));
|
||||
|
||||
const { id, email, name, role } = reqUser;
|
||||
const token = await sign({ id, email, name, role });
|
||||
|
||||
return ctx.json({ token });
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import { sendMail } from 'emailer';
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
export const forgotPasswordHandler: Handler = async function (ctx) {
|
||||
const { email } = ctx.get('body');
|
||||
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.email, email),
|
||||
});
|
||||
|
||||
if (!dbUser) return ctx.json({ ok: true });
|
||||
const verificationCode = await sign({ id: dbUser.id, email }, '6h');
|
||||
const url = `${PUBLIC_URL}/auth/reset-password?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'ForgotPassword',
|
||||
subject: 'Reset your Officer password',
|
||||
to: dbUser.email,
|
||||
data: { email: dbUser.email, url },
|
||||
});
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { Handler } from 'hono';
|
||||
import type { User } from 'types';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
|
||||
import { sign } from '../../jwt';
|
||||
import * as errors from '../../custom-errors';
|
||||
import { officerdb, eq, and, lt, Passkeys, Users, PasskeyChallenges } from 'officerdb';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
|
||||
|
||||
export const passkeyRouter = createRouter();
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const RP_NAME = 'Officer';
|
||||
|
||||
function getRpId(origin: string): string {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return 'officer.dev';
|
||||
}
|
||||
}
|
||||
|
||||
async function storeChallenge(email: string, origin: string, challenge: string) {
|
||||
await officerdb
|
||||
.insert(PasskeyChallenges)
|
||||
.values({ email, origin, challenge })
|
||||
.onConflictDoUpdate({
|
||||
target: [PasskeyChallenges.email, PasskeyChallenges.origin],
|
||||
set: { challenge, createdAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function getAndDeleteChallenge(email: string, origin: string): Promise<string | null> {
|
||||
const minValidTime = new Date(Date.now() - CHALLENGE_TTL_MS);
|
||||
|
||||
// Delete expired challenges for this email/origin
|
||||
await officerdb
|
||||
.delete(PasskeyChallenges)
|
||||
.where(
|
||||
and(
|
||||
eq(PasskeyChallenges.email, email),
|
||||
eq(PasskeyChallenges.origin, origin),
|
||||
lt(PasskeyChallenges.createdAt, minValidTime),
|
||||
),
|
||||
);
|
||||
|
||||
// Get and delete the challenge in one operation
|
||||
const result = await officerdb
|
||||
.delete(PasskeyChallenges)
|
||||
.where(and(eq(PasskeyChallenges.email, email), eq(PasskeyChallenges.origin, origin)))
|
||||
.returning();
|
||||
|
||||
const entry = result[0];
|
||||
if (!entry) return null;
|
||||
|
||||
// Double-check TTL (in case of race condition)
|
||||
if (entry.createdAt < minValidTime) return null;
|
||||
|
||||
return entry.challenge;
|
||||
}
|
||||
|
||||
// Generate registration options (challenge) for creating a new passkey
|
||||
const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
||||
const { email } = ctx.req.param();
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
|
||||
// Get existing passkeys to exclude them
|
||||
const existingPasskeys = await officerdb.query.Passkeys.findMany({
|
||||
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: rpId,
|
||||
userName: email!,
|
||||
userDisplayName: email!,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingPasskeys.map((p) => ({
|
||||
id: p.credentialId!,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge(email!, origin, options.challenge);
|
||||
return ctx.json(options);
|
||||
};
|
||||
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
|
||||
|
||||
// Verify and store new passkey registration
|
||||
const passkeyRouterPost: Handler = async (ctx) => {
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
const { email } = ctx.get('user') as User;
|
||||
const response = ctx.get('body') as RegistrationResponseJSON;
|
||||
|
||||
const storedChallenge = await getAndDeleteChallenge(email, origin);
|
||||
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: storedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw errors.BAD_CREDENTIALS();
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
|
||||
const values = {
|
||||
email,
|
||||
origin,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey).toString('base64'),
|
||||
counter: credential.counter,
|
||||
};
|
||||
|
||||
await officerdb.insert(Passkeys).values(values);
|
||||
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
|
||||
};
|
||||
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
|
||||
|
||||
// Generate authentication options for signing in with passkey
|
||||
const passkeyRouterGet: Handler = async (ctx) => {
|
||||
const { email } = ctx.req.param();
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
|
||||
const passkeys = await officerdb.query.Passkeys.findMany({
|
||||
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
|
||||
});
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: passkeys.map((p) => ({
|
||||
id: p.credentialId!,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
await storeChallenge(email!, origin, options.challenge);
|
||||
return ctx.json(options);
|
||||
};
|
||||
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
|
||||
|
||||
// Verify passkey authentication and issue token
|
||||
const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
const { email } = ctx.req.param();
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
const response = ctx.get('body') as AuthenticationResponseJSON;
|
||||
|
||||
const storedChallenge = await getAndDeleteChallenge(email!, origin);
|
||||
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
// Find the passkey being used
|
||||
const dbPasskey = await officerdb.query.Passkeys.findFirst({
|
||||
where: and(eq(Passkeys.email, email!), eq(Passkeys.credentialId, response.id)),
|
||||
});
|
||||
|
||||
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: storedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
credential: {
|
||||
id: dbPasskey.credentialId!,
|
||||
publicKey: Buffer.from(dbPasskey.publicKey, 'base64'),
|
||||
counter: dbPasskey.counter,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
// Update counter to prevent replay attacks
|
||||
await officerdb
|
||||
.update(Passkeys)
|
||||
.set({ counter: verification.authenticationInfo.newCounter })
|
||||
.where(eq(Passkeys.id, dbPasskey.id));
|
||||
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.email, email!),
|
||||
with: { passkeys: true },
|
||||
});
|
||||
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
const { id, name, role } = dbUser;
|
||||
const passkeys = dbUser.passkeys?.length ?? 0;
|
||||
const token = await sign({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
passkeys,
|
||||
});
|
||||
|
||||
return ctx.json({
|
||||
token,
|
||||
user: {
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
passkeys,
|
||||
},
|
||||
});
|
||||
};
|
||||
passkeyRouter.post('/verify/:email', passkeyRateLimiter, passkeyRouterPostVerify);
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { sendMail } from 'emailer';
|
||||
|
||||
export const resendVerificationHandler: Handler = async function (ctx) {
|
||||
const { email } = ctx.get('body');
|
||||
const origin = ctx.get('origin');
|
||||
|
||||
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
|
||||
|
||||
const user = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.email, email),
|
||||
});
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
||||
|
||||
const verificationCode = await sign({ id: user.id, email: user.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'VerifyAdmin',
|
||||
subject: 'Verify your Officer account',
|
||||
to: user.email,
|
||||
data: { name: user.email, url },
|
||||
});
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { User } from 'types';
|
||||
import type { Handler } from 'hono';
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import { verify } from '@@/jwt';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
|
||||
export const resetPasswordHandler: Handler = async function (ctx) {
|
||||
const now = Date.now().valueOf();
|
||||
const { password, verificationCode } = ctx.get('body');
|
||||
validatePassword(password);
|
||||
const userInfo = (await verify(verificationCode)) as User;
|
||||
if (!userInfo) throw errors.UNAUTHORIZED();
|
||||
const passwordHash = await argon2.hash(password);
|
||||
await officerdb
|
||||
.update(Users)
|
||||
.set({ password: passwordHash, status: 'Active', passwordChangedAt: now })
|
||||
.where(eq(Users.id, userInfo.id));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
};
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { officerdb, eq, and, Users, Passkeys } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import { getClaudeDir } from '@@/data-path';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
|
||||
const TEST_USERS: number[] = [];
|
||||
|
||||
export const signinHandler: Handler = async function (ctx) {
|
||||
const { email, password } = ctx.get('body');
|
||||
const origin = ctx.get('origin');
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.email, email),
|
||||
});
|
||||
|
||||
const passkeys = await officerdb.query.Passkeys.findMany({
|
||||
where: and(eq(Passkeys.email, email), eq(Passkeys.origin, origin)),
|
||||
});
|
||||
|
||||
if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED();
|
||||
const { status } = dbUser;
|
||||
if (status !== 'Active') throw errors.UNAUTHORIZED();
|
||||
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
|
||||
if (!isValidPassword) throw errors.UNAUTHORIZED();
|
||||
|
||||
const { id, name, role } = dbUser;
|
||||
|
||||
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
|
||||
|
||||
const tokenUser = { id, email, name, role, passkeys: passkeys.length };
|
||||
|
||||
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
|
||||
return ctx.json({ user: tokenUser });
|
||||
}
|
||||
|
||||
const token = await sign(tokenUser);
|
||||
|
||||
if (origin.startsWith('chrome-extension://')) {
|
||||
// console.log('token', token);
|
||||
}
|
||||
|
||||
return ctx.json({ token, user: tokenUser });
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { officerdb, TokenBlacklist, lt } from 'officerdb';
|
||||
|
||||
// Cleanup expired blacklist entries (can be called periodically)
|
||||
export async function cleanupExpiredTokens() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await officerdb.delete(TokenBlacklist).where(lt(TokenBlacklist.expiresAt, now));
|
||||
}
|
||||
|
||||
export const signoutHandler: Handler = async (ctx) => {
|
||||
const user = ctx.get('user') as { jti: string; exp: number };
|
||||
|
||||
// Add token to blacklist
|
||||
await officerdb
|
||||
.insert(TokenBlacklist)
|
||||
.values({
|
||||
jti: user.jti,
|
||||
expiresAt: user.exp,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Opportunistic cleanup of expired tokens (non-blocking)
|
||||
cleanupExpiredTokens().catch(() => {});
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { officerdb, count, Users } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import { USER_ROLES, USER_STATUSES } from 'definitions';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { sendMail } from 'emailer';
|
||||
|
||||
export const signupHandler: Handler = async function (ctx) {
|
||||
const body = ctx.get('body');
|
||||
const origin = ctx.get('origin');
|
||||
|
||||
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
||||
throw errors.BAD_REQUEST('Invalid email address');
|
||||
}
|
||||
|
||||
const result = await officerdb.select({ count: count() }).from(Users);
|
||||
const userCount = result[0]?.count ?? 0;
|
||||
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||
|
||||
const newUser = {
|
||||
email: body.email as string,
|
||||
status: 'Unverified' as (typeof USER_STATUSES)[number],
|
||||
role: 'Admin' as (typeof USER_ROLES)[number],
|
||||
};
|
||||
|
||||
const insertedUsers = await officerdb.insert(Users).values(newUser).returning();
|
||||
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
|
||||
const dbUser = insertedUsers[0]!;
|
||||
|
||||
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'VerifyAdmin',
|
||||
subject: 'Verify your Officer account',
|
||||
to: dbUser.email,
|
||||
data: { name: dbUser.email, url },
|
||||
});
|
||||
|
||||
return ctx.json({ ok: true, user: { id: dbUser.id, email: dbUser.email } });
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Handler } from 'hono';
|
||||
import type { User } from 'types';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { officerdb, eq, Users, Passkeys } from 'officerdb';
|
||||
|
||||
export const usersMe: Handler = async function (ctx) {
|
||||
const user = ctx.get('user') as User;
|
||||
const origin = ctx.get('origin') as string;
|
||||
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.id, user.id),
|
||||
columns: { password: false },
|
||||
with: {
|
||||
passkeys: { where: eq(Passkeys.origin, origin || '') },
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser) return errors.NOT_FOUND();
|
||||
|
||||
const { passkeys, ...userWithoutPasskeys } = dbUser;
|
||||
const returnUser = { ...userWithoutPasskeys, passkeyCount: passkeys.length };
|
||||
|
||||
return ctx.json(returnUser);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as errors from '@@/custom-errors';
|
||||
|
||||
export function validatePassword(password: string | undefined): void {
|
||||
if (!password || password.length < 12) {
|
||||
throw errors.BAD_REQUEST('Password must be at least 12 characters');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
throw errors.BAD_REQUEST('Password must contain lowercase letters');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
throw errors.BAD_REQUEST('Password must contain uppercase letters');
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
throw errors.BAD_REQUEST('Password must contain numbers');
|
||||
}
|
||||
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||
throw errors.BAD_REQUEST('Password must contain special characters (!@#$%^&* etc.)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Handler } from 'hono';
|
||||
import type { User } from 'types';
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import { verify } from '@@/jwt';
|
||||
import * as errors from '@@/custom-errors';
|
||||
|
||||
export const verifyTokenHandler: Handler = async function (ctx) {
|
||||
const { verificationCode } = ctx.get('body');
|
||||
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
||||
|
||||
let userInfo: User;
|
||||
try {
|
||||
userInfo = (await verify(verificationCode)) as User;
|
||||
} catch {
|
||||
throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||
}
|
||||
|
||||
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||
|
||||
const user = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.id, userInfo.id),
|
||||
});
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
||||
|
||||
return ctx.json({ ok: true, email: user.email });
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Handler } from 'hono';
|
||||
import type { User } from 'types';
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import { verify as verifyJwt, sign } from '@@/jwt';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
|
||||
export const verifyHandler: Handler = async function (ctx) {
|
||||
const { verificationCode, name, password, confirmPassword } = ctx.get('body');
|
||||
const userInfo = (await verifyJwt(verificationCode)) as User;
|
||||
if (!userInfo) throw errors.BAD_REQUEST();
|
||||
|
||||
const user = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.id, userInfo.id),
|
||||
});
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
const updates: Record<string, unknown> = { status: 'Active' };
|
||||
|
||||
if (name) {
|
||||
if (typeof name !== 'string' || !name.trim() || name.length > 128) {
|
||||
throw errors.BAD_REQUEST('Name must be between 1 and 128 characters');
|
||||
}
|
||||
updates.name = name.trim();
|
||||
}
|
||||
|
||||
if (password) {
|
||||
validatePassword(password);
|
||||
if (password !== confirmPassword) {
|
||||
throw errors.BAD_REQUEST('Passwords do not match');
|
||||
}
|
||||
updates.password = await argon2.hash(password);
|
||||
}
|
||||
|
||||
await officerdb.update(Users).set(updates).where(eq(Users.id, userInfo.id));
|
||||
|
||||
// Issue a token so the user is logged in immediately
|
||||
const token = await sign({ id: userInfo.id, email: userInfo.email });
|
||||
|
||||
return ctx.json({ ok: true, token });
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// Client → Server
|
||||
export type ImageData = { mediaType: string; data: string };
|
||||
|
||||
export type TaskInfo = {
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
};
|
||||
|
||||
export type ClientMessage =
|
||||
| {
|
||||
type: 'chat';
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
model?: string | { providerID?: string; modelID?: string };
|
||||
cwd?: { root?: string; path: string };
|
||||
attachmentIds?: string[];
|
||||
images?: ImageData[];
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
}
|
||||
| { type: 'stop' };
|
||||
|
||||
// Server → Client
|
||||
export type ServerMessage =
|
||||
| { type: 'session:init'; sessionId: string; model: string }
|
||||
| { type: 'assistant:text'; text: string }
|
||||
| { type: 'assistant:partial'; text: string }
|
||||
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
|
||||
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
|
||||
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'stopped' };
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Hono } from 'hono';
|
||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getClaudeDir, getSessionDir, getArchivedSessionDir } from '@@/data-path';
|
||||
import type { HonoVariables } from '@@/create-router';
|
||||
|
||||
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
sessionsRouter.get('/claude/models', async (ctx) => {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) return ctx.json([]);
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
|
||||
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||||
});
|
||||
if (!res.ok) return ctx.json([]);
|
||||
|
||||
const data = (await res.json()) as { data?: { id: string; display_name: string }[] };
|
||||
const models = (data.data ?? []).map((m) => ({ id: m.id, name: m.display_name }));
|
||||
return ctx.json(models);
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
sessionsRouter.get('/sessions', async (ctx) => {
|
||||
const { email } = ctx.get('user');
|
||||
const dir = getClaudeDir(email);
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
const sessions = await Promise.all(
|
||||
entries
|
||||
.filter((name) => name !== 'archived')
|
||||
.map(async (id) => {
|
||||
try {
|
||||
const metaFile = Bun.file(join(dir, id, 'meta.json'));
|
||||
if (!(await metaFile.exists())) return null;
|
||||
return await metaFile.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const valid = sessions.filter(Boolean);
|
||||
valid.sort((a: any, b: any) => b.createdAt - a.createdAt);
|
||||
return ctx.json(valid);
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
sessionsRouter.get('/sessions/:id/messages', async (ctx) => {
|
||||
const { email } = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
|
||||
|
||||
if (!(await file.exists())) return ctx.json([]);
|
||||
const messages = await file.json();
|
||||
return ctx.json(messages);
|
||||
});
|
||||
|
||||
sessionsRouter.put('/sessions/:id/messages', async (ctx) => {
|
||||
const { email } = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
const messages = ctx.get('body');
|
||||
const dir = getSessionDir(email, id);
|
||||
|
||||
await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages));
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
sessionsRouter.put('/sessions/:id', async (ctx) => {
|
||||
const { email } = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
const body = ctx.get('body') as { title?: string };
|
||||
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
|
||||
|
||||
const dir = getSessionDir(email, id);
|
||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||
|
||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||
|
||||
const meta = await metaFile.json();
|
||||
meta.title = body.title.slice(0, 200);
|
||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
sessionsRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const { email } = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
const dir = getSessionDir(email, id);
|
||||
|
||||
try {
|
||||
await rm(dir, { recursive: true });
|
||||
} catch {
|
||||
// dir may not exist
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
sessionsRouter.post('/sessions/:id/archive', async (ctx) => {
|
||||
const { email } = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
const src = getSessionDir(email, id);
|
||||
const dest = getArchivedSessionDir(email, id);
|
||||
|
||||
await mkdir(join(getClaudeDir(email), 'archived'), { recursive: true });
|
||||
await rename(src, dest);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export type { ClientMessage, ServerMessage } from '@@/api/chat-types';
|
||||
@@ -0,0 +1,390 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { mkdir, rename } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
import {
|
||||
getSessionDir,
|
||||
getTmpAttachmentsDir,
|
||||
getAttachmentsDir,
|
||||
getHomeDir,
|
||||
getNativeSkillsDir,
|
||||
getGlobalSkillsDir,
|
||||
getUserSkillsDir,
|
||||
} from '@@/data-path';
|
||||
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
|
||||
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
||||
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
||||
|
||||
type WSData = { userId: number; email: string };
|
||||
|
||||
type ConnectionState = {
|
||||
abortController: AbortController | null;
|
||||
currentSessionId: string | null;
|
||||
pendingTitle: string | null;
|
||||
selectedModel: string | null;
|
||||
pendingAttachmentIds: string[];
|
||||
cwd: string | null;
|
||||
resourceChatDir: string | null;
|
||||
logId: string | null;
|
||||
};
|
||||
|
||||
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
|
||||
|
||||
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
type HandleChatParams = {
|
||||
ws: ServerWebSocket<WSData>;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
attachmentIds?: string[];
|
||||
images?: ImageData[];
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
function resolveRootDir(email: string, root?: string): string {
|
||||
if (!root || root === 'home') return getHomeDir(email);
|
||||
if (root === '~') return homedir();
|
||||
if (root === 'officer.dev') return resolve(process.cwd(), '..');
|
||||
return getHomeDir(email);
|
||||
}
|
||||
|
||||
async function buildSkillsPrompt(email: string): Promise<string> {
|
||||
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||
const userSkills = await readSkillDirs(getUserSkillsDir(email));
|
||||
|
||||
const merged = new Map(nativeSkills);
|
||||
for (const [name, path] of globalSkills) merged.set(name, path);
|
||||
for (const [name, path] of userSkills) merged.set(name, path);
|
||||
|
||||
if (merged.size === 0) return '';
|
||||
|
||||
const lines = await Promise.all(
|
||||
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter } = parseFrontmatter(raw);
|
||||
const name = frontmatter.name || dirName;
|
||||
return `- ${name}: ${frontmatter.description} (read ${filePath} for full instructions)`;
|
||||
}),
|
||||
);
|
||||
|
||||
return `\n\nYou have access to the following skills. When a user's request matches a skill, read its SKILL.md file for detailed instructions before proceeding.\n\nAvailable skills:\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
async function handleChat({
|
||||
ws,
|
||||
prompt,
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
attachmentIds,
|
||||
images,
|
||||
resourceChatDir,
|
||||
taskInfo,
|
||||
}: HandleChatParams) {
|
||||
const state = connections.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
if (taskInfo && !state.logId) {
|
||||
state.logId = createTaskLog(ws.data.email, taskInfo, 'claude', model ?? 'unknown');
|
||||
appendToLog(state.logId, { role: 'user', text: prompt });
|
||||
}
|
||||
|
||||
if (resourceChatDir) state.resourceChatDir = resourceChatDir;
|
||||
|
||||
if (model) {
|
||||
state.selectedModel = model;
|
||||
// Persist model choice to meta.json if session exists
|
||||
if (sessionId) {
|
||||
const dir = state.resourceChatDir ? join(state.resourceChatDir, 'chat') : getSessionDir(ws.data.email, sessionId);
|
||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||
metaFile
|
||||
.json()
|
||||
.then((meta: Record<string, unknown>) => {
|
||||
meta.model = model;
|
||||
return Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
state.pendingTitle = prompt.slice(0, 100);
|
||||
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
|
||||
}
|
||||
|
||||
// Abort previous generation if any
|
||||
if (state.abortController) {
|
||||
state.abortController.abort();
|
||||
state.abortController = null;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
state.abortController = abortController;
|
||||
|
||||
try {
|
||||
const homeDir = getHomeDir(ws.data.email);
|
||||
if (cwd) state.cwd = join(resolveRootDir(ws.data.email, cwd.root), cwd.path);
|
||||
const workingDir = state.cwd ?? homeDir;
|
||||
|
||||
const skillsAppend = await buildSkillsPrompt(ws.data.email);
|
||||
|
||||
// Build prompt: use AsyncIterable<SDKUserMessage> with image content blocks when images are present
|
||||
let promptInput: string | AsyncIterable<SDKUserMessage> = prompt;
|
||||
if (images?.length) {
|
||||
const content: any[] = [];
|
||||
for (const img of images) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
||||
});
|
||||
}
|
||||
content.push({ type: 'text', text: prompt });
|
||||
|
||||
async function* generateMessage(): AsyncIterable<SDKUserMessage> {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: { role: 'user', content },
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionId ?? '',
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
promptInput = generateMessage();
|
||||
}
|
||||
|
||||
const stream = query({
|
||||
prompt: promptInput,
|
||||
options: {
|
||||
abortController,
|
||||
cwd: workingDir,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
systemPrompt: {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
...(skillsAppend ? { append: skillsAppend } : {}),
|
||||
},
|
||||
additionalDirectories: [],
|
||||
includePartialMessages: true,
|
||||
...(state.selectedModel ? { model: state.selectedModel } : {}),
|
||||
...(sessionId ? { resume: sessionId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
for await (const message of stream) {
|
||||
if (abortController.signal.aborted) break;
|
||||
|
||||
console.log('[claude-ws] message type:', message.type, 'subtype' in message ? message.subtype : '');
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
state.currentSessionId = message.session_id;
|
||||
send(ws, { type: 'session:init', sessionId: message.session_id, model: message.model });
|
||||
|
||||
if (state.resourceChatDir) {
|
||||
// Store session in resource's chat/ subdirectory
|
||||
const chatDir = join(state.resourceChatDir, 'chat');
|
||||
const meta = { id: message.session_id, model: message.model };
|
||||
mkdir(chatDir, { recursive: true })
|
||||
.then(() => Bun.write(join(chatDir, 'meta.json'), JSON.stringify(meta)))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
// Default: store in global sessions directory
|
||||
const dir = getSessionDir(ws.data.email, message.session_id);
|
||||
const meta = {
|
||||
id: message.session_id,
|
||||
title: state.pendingTitle ?? 'New chat',
|
||||
createdAt: Date.now(),
|
||||
model: message.model,
|
||||
};
|
||||
mkdir(dir, { recursive: true })
|
||||
.then(() => Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)))
|
||||
.catch(() => {});
|
||||
|
||||
// Move tmp attachments to session dir
|
||||
if (state.pendingAttachmentIds.length > 0) {
|
||||
const tmpDir = getTmpAttachmentsDir(ws.data.email);
|
||||
const destDir = getAttachmentsDir(ws.data.email, 'claude', message.session_id);
|
||||
mkdir(destDir, { recursive: true })
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
state.pendingAttachmentIds = [];
|
||||
}
|
||||
}
|
||||
state.pendingTitle = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'assistant') {
|
||||
const content = (message as any).message?.content;
|
||||
console.log('[claude-ws] assistant content:', JSON.stringify(content)?.slice(0, 500));
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') {
|
||||
send(ws, { type: 'assistant:text', text: block.text });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: block.text });
|
||||
} else if (block.type === 'tool_use') {
|
||||
send(ws, {
|
||||
type: 'tool:use',
|
||||
toolName: block.name,
|
||||
toolInput: block.input as Record<string, unknown>,
|
||||
toolUseId: block.id,
|
||||
});
|
||||
if (state.logId)
|
||||
appendToLog(state.logId, {
|
||||
role: 'tool',
|
||||
toolName: block.name,
|
||||
toolInput: block.input as Record<string, unknown>,
|
||||
toolUseId: block.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'user') {
|
||||
const content = (message as any).message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
const output =
|
||||
typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text: string }) => c.text)
|
||||
.join('\n')
|
||||
: '';
|
||||
send(ws, {
|
||||
type: 'tool:result',
|
||||
toolUseId: block.tool_use_id,
|
||||
output,
|
||||
isError: !!block.is_error,
|
||||
});
|
||||
if (state.logId)
|
||||
appendToLog(state.logId, {
|
||||
role: 'tool',
|
||||
toolName: '',
|
||||
toolInput: {},
|
||||
toolUseId: block.tool_use_id,
|
||||
output,
|
||||
isError: !!block.is_error,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'stream_event') {
|
||||
const ev = (message as any).event;
|
||||
console.log('[claude-ws] stream_event:', ev?.type, ev?.delta?.type);
|
||||
if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
|
||||
send(ws, { type: 'assistant:partial', text: ev.delta.text });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
send(ws, {
|
||||
type: 'result',
|
||||
costUsd: message.total_cost_usd,
|
||||
durationMs: message.duration_ms,
|
||||
numTurns: message.num_turns,
|
||||
isError: message.is_error,
|
||||
});
|
||||
if (state.logId) {
|
||||
appendToLog(state.logId, {
|
||||
role: 'result',
|
||||
costUsd: message.total_cost_usd,
|
||||
durationMs: message.duration_ms,
|
||||
numTurns: message.num_turns,
|
||||
isError: message.is_error,
|
||||
});
|
||||
finalizeLog(state.logId);
|
||||
state.logId = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'Unknown error' });
|
||||
}
|
||||
if (state.logId) {
|
||||
appendToLog(state.logId, { role: 'error', text: err instanceof Error ? err.message : 'Unknown error' });
|
||||
finalizeLog(state.logId);
|
||||
state.logId = null;
|
||||
}
|
||||
} finally {
|
||||
if (state.abortController === abortController) {
|
||||
state.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const claudeWebsocket = {
|
||||
open(ws: ServerWebSocket<WSData>) {
|
||||
connections.set(ws, {
|
||||
abortController: null,
|
||||
currentSessionId: null,
|
||||
pendingTitle: null,
|
||||
selectedModel: null,
|
||||
pendingAttachmentIds: [],
|
||||
cwd: null,
|
||||
resourceChatDir: null,
|
||||
logId: null,
|
||||
});
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
let msg: ClientMessage;
|
||||
try {
|
||||
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as ClientMessage;
|
||||
} catch {
|
||||
send(ws, { type: 'error', message: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'chat') {
|
||||
handleChat({
|
||||
ws,
|
||||
prompt: msg.prompt,
|
||||
sessionId: msg.sessionId,
|
||||
model: msg.model,
|
||||
cwd: msg.cwd,
|
||||
attachmentIds: msg.attachmentIds,
|
||||
images: msg.images,
|
||||
resourceChatDir: msg.resourceChatDir,
|
||||
taskInfo: msg.taskInfo,
|
||||
});
|
||||
} else if (msg.type === 'stop') {
|
||||
const state = connections.get(ws);
|
||||
if (state?.abortController) {
|
||||
state.abortController.abort();
|
||||
state.abortController = null;
|
||||
}
|
||||
send(ws, { type: 'stopped' });
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (state?.abortController) {
|
||||
state.abortController.abort();
|
||||
}
|
||||
connections.delete(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { authRouter } from './auth';
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { HonoVariables } from '@@/create-router';
|
||||
|
||||
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
|
||||
const BASE = `http://localhost:${OPENCODE_PORT}`;
|
||||
|
||||
export const opencodeSessionsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
// List all active models from OpenCode — visibility filtering is handled client-side
|
||||
opencodeSessionsRouter.get('/models', async (ctx) => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/provider`);
|
||||
if (!res.ok) return ctx.json([]);
|
||||
const data = (await res.json()) as { all?: Record<string, unknown>[] };
|
||||
const providers = Array.isArray(data.all) ? data.all : [];
|
||||
const models: { id: string; name: string; provider: string; providerId: string }[] = [];
|
||||
for (const p of providers as Record<string, unknown>[]) {
|
||||
const providerId = (p.id as string) ?? '';
|
||||
const providerName = (p.name as string) ?? providerId;
|
||||
const modelMap = (p.models ?? {}) as Record<string, Record<string, unknown>>;
|
||||
for (const m of Object.values(modelMap)) {
|
||||
if (m.status === 'active') {
|
||||
models.push({
|
||||
id: m.id as string,
|
||||
name: (m.name as string) ?? (m.id as string),
|
||||
provider: providerName,
|
||||
providerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx.json(models);
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// List sessions — proxy to OpenCode API (SQLite-backed)
|
||||
opencodeSessionsRouter.get('/sessions', async (ctx) => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/session`);
|
||||
if (!res.ok) return ctx.json([]);
|
||||
const data = (await res.json()) as any[];
|
||||
const sessions = data.map((s: any) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? 'Untitled',
|
||||
createdAt: s.time?.created ?? 0,
|
||||
}));
|
||||
sessions.sort((a: any, b: any) => b.createdAt - a.createdAt);
|
||||
return ctx.json(sessions);
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Get session messages — proxy to OpenCode
|
||||
opencodeSessionsRouter.get('/sessions/:id/messages', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
try {
|
||||
const res = await fetch(`${BASE}/session/${id}/message`);
|
||||
if (!res.ok) return ctx.json([]);
|
||||
|
||||
const data = (await res.json()) as any[];
|
||||
const messages = Array.isArray(data) ? data : Object.values(data);
|
||||
|
||||
const chatMessages: any[] = [];
|
||||
for (const msg of messages) {
|
||||
const role = msg.info?.role ?? msg.role;
|
||||
if (role === 'user') {
|
||||
const text = Array.isArray(msg.parts)
|
||||
? msg.parts
|
||||
.filter((p: any) => p.type === 'text')
|
||||
.map((p: any) => p.text ?? p.content ?? '')
|
||||
.join('')
|
||||
: typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: '';
|
||||
if (text) chatMessages.push({ role: 'user', text });
|
||||
} else if (role === 'assistant') {
|
||||
if (Array.isArray(msg.parts)) {
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === 'text' && (part.text || part.content)) {
|
||||
chatMessages.push({ role: 'assistant', text: part.text ?? part.content ?? '' });
|
||||
} else if (part.type === 'tool') {
|
||||
chatMessages.push({
|
||||
role: 'tool',
|
||||
toolName: part.tool ?? 'unknown',
|
||||
toolInput: part.state?.input ?? {},
|
||||
toolUseId: part.callID ?? part.id ?? '',
|
||||
output:
|
||||
part.state?.output != null
|
||||
? typeof part.state.output === 'string'
|
||||
? part.state.output
|
||||
: JSON.stringify(part.state.output)
|
||||
: undefined,
|
||||
isError: part.state?.status === 'error',
|
||||
});
|
||||
} else if (part.type === 'tool-invocation') {
|
||||
const inv = part.toolInvocation ?? part;
|
||||
chatMessages.push({
|
||||
role: 'tool',
|
||||
toolName: inv.toolName ?? 'unknown',
|
||||
toolInput: inv.args ?? {},
|
||||
toolUseId: inv.toolCallId ?? part.id ?? '',
|
||||
output:
|
||||
inv.result != null
|
||||
? typeof inv.result === 'string'
|
||||
? inv.result
|
||||
: JSON.stringify(inv.result)
|
||||
: undefined,
|
||||
isError: !!part.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(chatMessages);
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Rename session — proxy to OpenCode API
|
||||
opencodeSessionsRouter.put('/sessions/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
const body = ctx.get('body') as { title?: string };
|
||||
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE}/session/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: body.title.slice(0, 200) }),
|
||||
});
|
||||
if (!res.ok) return ctx.json({ error: 'failed to rename' }, 500);
|
||||
return ctx.json({ ok: true });
|
||||
} catch {
|
||||
return ctx.json({ error: 'failed to rename' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete session — proxy to OpenCode API
|
||||
opencodeSessionsRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
try {
|
||||
await fetch(`${BASE}/session/${id}`, { method: 'DELETE' });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { mkdir, rename } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
||||
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
||||
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
||||
|
||||
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
|
||||
const BASE = `http://localhost:${OPENCODE_PORT}`;
|
||||
|
||||
// Cache model → provider mapping so we can send providerID alongside modelID
|
||||
let modelProviderMap = new Map<string, string>();
|
||||
let providerIdSet = new Set<string>();
|
||||
let modelProviderMapAge = 0;
|
||||
const MODEL_MAP_TTL = 5 * 60 * 1000;
|
||||
|
||||
async function refreshProviderMap() {
|
||||
if (Date.now() - modelProviderMapAge > MODEL_MAP_TTL || modelProviderMapAge === 0) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/provider`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { all?: Record<string, unknown>[] };
|
||||
const providers = Array.isArray(data.all) ? data.all : [];
|
||||
const newMap = new Map<string, string>();
|
||||
const newProviderSet = new Set<string>();
|
||||
for (const p of providers as Record<string, unknown>[]) {
|
||||
const pid = (p.id as string) ?? (p.name as string) ?? '';
|
||||
if (pid) newProviderSet.add(pid);
|
||||
const modelMap = (p.models ?? {}) as Record<string, Record<string, unknown>>;
|
||||
for (const m of Object.values(modelMap)) {
|
||||
if (m.id) newMap.set(m.id as string, pid);
|
||||
}
|
||||
}
|
||||
modelProviderMap = newMap;
|
||||
providerIdSet = newProviderSet;
|
||||
modelProviderMapAge = Date.now();
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getProviderForModel(modelId: string): Promise<string | null> {
|
||||
await refreshProviderMap();
|
||||
return modelProviderMap.get(modelId) ?? null;
|
||||
}
|
||||
|
||||
async function resolveModelSelection(model: string | undefined): Promise<{ providerId: string | null; modelId: string | null }> {
|
||||
if (!model) return { providerId: null, modelId: null };
|
||||
await refreshProviderMap();
|
||||
for (const pid of providerIdSet) {
|
||||
if (model.startsWith(`${pid}/`)) {
|
||||
return { providerId: pid, modelId: model.slice(pid.length + 1) };
|
||||
}
|
||||
}
|
||||
const providerId = modelProviderMap.get(model) ?? null;
|
||||
if (providerId) return { providerId, modelId: model };
|
||||
|
||||
if (model.includes('/')) {
|
||||
const lastSegment = model.split('/').pop() ?? '';
|
||||
const fallbackProviderId = lastSegment ? modelProviderMap.get(lastSegment) ?? null : null;
|
||||
if (fallbackProviderId) return { providerId: fallbackProviderId, modelId: lastSegment };
|
||||
}
|
||||
|
||||
return { providerId: null, modelId: model };
|
||||
}
|
||||
|
||||
type WSData = { userId: number; email: string };
|
||||
|
||||
type ConnectionState = {
|
||||
sseAbort: AbortController | null;
|
||||
sessionId: string | null;
|
||||
pendingAttachmentIds: string[];
|
||||
lastTextLength: Map<string, number>;
|
||||
fullText: Map<string, string>;
|
||||
userMessageIds: Set<string>;
|
||||
isBusy: boolean;
|
||||
logId: string | null;
|
||||
};
|
||||
|
||||
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
|
||||
|
||||
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
async function connectSSE(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
const abort = new AbortController();
|
||||
state.sseAbort = abort;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE}/event`, { signal: abort.signal });
|
||||
if (!res.ok || !res.body) {
|
||||
send(ws, { type: 'error', message: `SSE connect failed: ${res.status}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || abort.signal.aborted) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventType = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
// OpenCode embeds event type in data.type, payload in data.properties
|
||||
const type = eventType || data.type || '';
|
||||
handleSSEEvent(ws, type, data.properties ?? data);
|
||||
} catch {
|
||||
// skip unparseable data
|
||||
}
|
||||
eventType = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!abort.signal.aborted) {
|
||||
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'SSE error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEventSessionId(event: string, data: any): string | null {
|
||||
switch (event) {
|
||||
case 'message.updated':
|
||||
return data.info?.sessionID ?? null;
|
||||
case 'message.part.updated':
|
||||
return (data.part ?? data)?.sessionID ?? null;
|
||||
case 'session.status':
|
||||
case 'session.idle':
|
||||
case 'session.error':
|
||||
return data.sessionID ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function emitCompletion(ws: ServerWebSocket<WSData>, state: ConnectionState) {
|
||||
// Send accumulated text as a reliable final message before result
|
||||
const texts: string[] = [];
|
||||
for (const [, text] of state.fullText) {
|
||||
if (text) texts.push(text);
|
||||
}
|
||||
if (texts.length > 0) {
|
||||
const fullText = texts.join('');
|
||||
send(ws, { type: 'assistant:text', text: fullText });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: fullText });
|
||||
}
|
||||
state.isBusy = false;
|
||||
state.lastTextLength.clear();
|
||||
state.fullText.clear();
|
||||
state.userMessageIds.clear();
|
||||
send(ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
if (state.logId) {
|
||||
appendToLog(state.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
finalizeLog(state.logId);
|
||||
state.logId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSSEEvent(ws: ServerWebSocket<WSData>, event: string, data: any) {
|
||||
const state = connections.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
// Filter events by session — ignore events from other sessions
|
||||
if (state.sessionId) {
|
||||
const eventSid = getEventSessionId(event, data);
|
||||
if (eventSid && eventSid !== state.sessionId) return;
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case 'session.created':
|
||||
state.sessionId = data.info?.id ?? data.id;
|
||||
send(ws, {
|
||||
type: 'session:init',
|
||||
sessionId: state.sessionId!,
|
||||
model: data.info?.model ?? data.model ?? 'opencode',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'message.updated': {
|
||||
const info = data.info;
|
||||
if (info?.role === 'user' && info.id) {
|
||||
state.userMessageIds.add(info.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message.part.updated': {
|
||||
const part = data.part ?? data;
|
||||
// Skip user message parts (would echo user's prompt as assistant text)
|
||||
if (part.messageID && state.userMessageIds.has(part.messageID)) break;
|
||||
if (part.type === 'text') {
|
||||
const partId = part.id ?? 'text';
|
||||
const lastLen = state.lastTextLength.get(partId) ?? 0;
|
||||
const content = part.text ?? part.content ?? '';
|
||||
// Track full text for reliable commit on idle
|
||||
state.fullText.set(partId, content);
|
||||
if (content.length > lastLen) {
|
||||
const delta = content.slice(lastLen);
|
||||
state.lastTextLength.set(partId, content.length);
|
||||
send(ws, { type: 'assistant:partial', text: delta });
|
||||
}
|
||||
} else if (part.type === 'tool') {
|
||||
const status = part.state?.status;
|
||||
const callId = part.callID ?? part.id ?? '';
|
||||
if (status === 'pending') {
|
||||
send(ws, {
|
||||
type: 'tool:use',
|
||||
toolName: part.tool ?? 'unknown',
|
||||
toolInput: part.state?.input ?? {},
|
||||
toolUseId: callId,
|
||||
});
|
||||
if (state.logId)
|
||||
appendToLog(state.logId, {
|
||||
role: 'tool',
|
||||
toolName: part.tool ?? 'unknown',
|
||||
toolInput: part.state?.input ?? {},
|
||||
toolUseId: callId,
|
||||
});
|
||||
} else if (status === 'running') {
|
||||
// Running state has the actual input — update the tool
|
||||
const input = part.state?.input;
|
||||
if (input && Object.keys(input).length > 0) {
|
||||
send(ws, {
|
||||
type: 'tool:use',
|
||||
toolName: part.tool ?? 'unknown',
|
||||
toolInput: input,
|
||||
toolUseId: callId,
|
||||
});
|
||||
}
|
||||
} else if (status === 'completed' || status === 'error') {
|
||||
const output =
|
||||
part.state?.output != null
|
||||
? typeof part.state.output === 'string'
|
||||
? part.state.output
|
||||
: JSON.stringify(part.state.output)
|
||||
: '';
|
||||
send(ws, {
|
||||
type: 'tool:result',
|
||||
toolUseId: callId,
|
||||
output,
|
||||
isError: status === 'error',
|
||||
});
|
||||
if (state.logId)
|
||||
appendToLog(state.logId, {
|
||||
role: 'tool',
|
||||
toolName: part.tool ?? 'unknown',
|
||||
toolInput: {},
|
||||
toolUseId: callId,
|
||||
output,
|
||||
isError: status === 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session.status': {
|
||||
const status = data.status?.type;
|
||||
if (status === 'busy') {
|
||||
state.isBusy = true;
|
||||
} else if (status === 'idle' && state.isBusy) {
|
||||
emitCompletion(ws, state);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session.idle':
|
||||
if (state.isBusy) {
|
||||
emitCompletion(ws, state);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'session.error': {
|
||||
const errMsg = data.error ?? data.message ?? 'OpenCode error';
|
||||
send(ws, { type: 'error', message: errMsg });
|
||||
if (state.logId) {
|
||||
appendToLog(state.logId, { role: 'error', text: errMsg });
|
||||
finalizeLog(state.logId);
|
||||
state.logId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type HandleChatParams = {
|
||||
ws: ServerWebSocket<WSData>;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
model?: string | { providerID?: string; modelID?: string };
|
||||
attachmentIds?: string[];
|
||||
images?: ImageData[];
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
async function handleChat({ ws, prompt, sessionId, model, attachmentIds, images, taskInfo }: HandleChatParams) {
|
||||
const state = connections.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
const modelSelection =
|
||||
typeof model === 'object' && model?.modelID
|
||||
? { providerId: model.providerID ?? null, modelId: model.modelID ?? null }
|
||||
: await resolveModelSelection(model);
|
||||
const modelRef =
|
||||
modelSelection.providerId && modelSelection.modelId
|
||||
? `${modelSelection.providerId}/${modelSelection.modelId}`
|
||||
: modelSelection.modelId;
|
||||
|
||||
if (taskInfo && !state.logId) {
|
||||
state.logId = createTaskLog(ws.data.email, taskInfo, 'opencode', modelRef ?? 'unknown');
|
||||
appendToLog(state.logId, { role: 'user', text: prompt });
|
||||
}
|
||||
|
||||
try {
|
||||
let sid = sessionId ?? state.sessionId;
|
||||
if (sid) state.sessionId = sid;
|
||||
|
||||
if (!sid) {
|
||||
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
|
||||
|
||||
console.log('[opencode-ws] POST /session body:', JSON.stringify({}));
|
||||
const res = await fetch(`${BASE}/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
send(ws, { type: 'error', message: `Failed to create session: ${res.status}` });
|
||||
return;
|
||||
}
|
||||
const session = (await res.json()) as { id: string; model?: string };
|
||||
sid = session.id;
|
||||
state.sessionId = sid;
|
||||
send(ws, { type: 'session:init', sessionId: sid, model: session.model ?? 'opencode' });
|
||||
|
||||
// Move tmp attachments to session dir
|
||||
if (state.pendingAttachmentIds.length > 0) {
|
||||
const tmpDir = getTmpAttachmentsDir(ws.data.email);
|
||||
const destDir = getAttachmentsDir(ws.data.email, 'opencode', sid);
|
||||
mkdir(destDir, { recursive: true })
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
state.pendingAttachmentIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
const parts: Record<string, unknown>[] = [];
|
||||
if (images?.length) {
|
||||
for (const img of images) {
|
||||
parts.push({
|
||||
type: 'file',
|
||||
mime: img.mediaType,
|
||||
url: `data:${img.mediaType};base64,${img.data}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
parts.push({ type: 'text', text: prompt });
|
||||
|
||||
const promptBody: Record<string, unknown> = { parts };
|
||||
if (modelSelection.modelId) {
|
||||
promptBody.model = {
|
||||
modelID: modelSelection.modelId,
|
||||
...(modelSelection.providerId ? { providerID: modelSelection.providerId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[opencode-ws] POST /session/prompt_async body:', JSON.stringify(promptBody));
|
||||
console.log(
|
||||
'[opencode-ws] model selection:',
|
||||
JSON.stringify({ providerID: modelSelection.providerId, modelID: modelSelection.modelId }),
|
||||
);
|
||||
const res = await fetch(`${BASE}/session/${sid}/prompt_async`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(promptBody),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
send(ws, { type: 'error', message: `Prompt failed: ${res.status}` });
|
||||
}
|
||||
} catch (err) {
|
||||
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'Failed to send prompt' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (!state?.sessionId) return;
|
||||
|
||||
try {
|
||||
await fetch(`${BASE}/session/${state.sessionId}/abort`, { method: 'POST' });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
send(ws, { type: 'stopped' });
|
||||
}
|
||||
|
||||
export const opencodeWebsocket = {
|
||||
open(ws: ServerWebSocket<WSData>) {
|
||||
connections.set(ws, {
|
||||
sseAbort: null,
|
||||
sessionId: null,
|
||||
pendingAttachmentIds: [],
|
||||
lastTextLength: new Map(),
|
||||
fullText: new Map(),
|
||||
userMessageIds: new Set(),
|
||||
isBusy: false,
|
||||
logId: null,
|
||||
});
|
||||
connectSSE(ws);
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
let msg: ClientMessage;
|
||||
try {
|
||||
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as ClientMessage;
|
||||
} catch {
|
||||
send(ws, { type: 'error', message: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'chat') {
|
||||
handleChat({
|
||||
ws,
|
||||
prompt: msg.prompt,
|
||||
sessionId: msg.sessionId,
|
||||
model: msg.model,
|
||||
attachmentIds: msg.attachmentIds,
|
||||
images: msg.images,
|
||||
taskInfo: msg.taskInfo,
|
||||
});
|
||||
} else if (msg.type === 'stop') {
|
||||
handleStop(ws);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (state?.sseAbort) {
|
||||
state.sseAbort.abort();
|
||||
}
|
||||
connections.delete(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const plansDir = join(process.cwd(), 'plans');
|
||||
|
||||
export const plansRouter = createRouter();
|
||||
|
||||
plansRouter.get('/', async (ctx) => {
|
||||
try {
|
||||
const files = await readdir(plansDir);
|
||||
const plans = files.filter((f) => f.endsWith('.md')).map((f) => f.replace('.md', ''));
|
||||
return ctx.json(plans);
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
plansRouter.get('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const filePath = join(plansDir, `${name}.md`);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return ctx.text('Not found', 404);
|
||||
const text = await file.text();
|
||||
return ctx.text(text);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readdir, mkdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { getNativeProcessesDir, getGlobalProcessesDir, getUserProcessesDir } from '../../data-path';
|
||||
|
||||
type Frontmatter = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: { name: '', description: '' }, body: raw };
|
||||
|
||||
const yaml = match[1]!;
|
||||
const body = match[2]!;
|
||||
|
||||
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
|
||||
return { frontmatter: { name, description }, body };
|
||||
}
|
||||
|
||||
export async function readProcessDirs(dir: string): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const processFile = join(dir, entry.name, 'PROCESS.md');
|
||||
if (await Bun.file(processFile).exists()) {
|
||||
result.set(entry.name, processFile);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// directory doesn't exist yet
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
type Scope = 'native' | 'global' | 'user';
|
||||
|
||||
function resolveScope(dirName: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): Scope {
|
||||
if (user.has(dirName)) return 'user';
|
||||
if (global.has(dirName)) return 'global';
|
||||
return 'native';
|
||||
}
|
||||
|
||||
function resolveFile(name: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): { filePath: string; scope: Scope } | null {
|
||||
if (user.has(name)) return { filePath: user.get(name)!, scope: 'user' };
|
||||
if (global.has(name)) return { filePath: global.get(name)!, scope: 'global' };
|
||||
if (native.has(name)) return { filePath: native.get(name)!, scope: 'native' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const processesRouter = createRouter();
|
||||
|
||||
processesRouter.get('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
|
||||
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
|
||||
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
|
||||
|
||||
const merged = new Map(nativeProcesses);
|
||||
for (const [name, path] of globalProcesses) merged.set(name, path);
|
||||
for (const [name, path] of userProcesses) merged.set(name, path);
|
||||
|
||||
const processes = await Promise.all(
|
||||
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter } = parseFrontmatter(raw);
|
||||
const scope = resolveScope(dirName, nativeProcesses, globalProcesses, userProcesses);
|
||||
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope };
|
||||
}),
|
||||
);
|
||||
|
||||
return ctx.json(processes);
|
||||
});
|
||||
|
||||
processesRouter.get('/:name', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
|
||||
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
|
||||
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const raw = await Bun.file(resolved.filePath).text();
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
|
||||
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
|
||||
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
|
||||
return ctx.json({
|
||||
name: frontmatter.name || name,
|
||||
description: frontmatter.description,
|
||||
scope: resolved.scope,
|
||||
body,
|
||||
filePath: resolved.filePath,
|
||||
chatSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
processesRouter.get('/:name/chat', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
|
||||
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
|
||||
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
|
||||
|
||||
return ctx.json({ sessionId, messages });
|
||||
});
|
||||
|
||||
processesRouter.put('/:name/chat', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
|
||||
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
|
||||
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
await mkdir(chatDir, { recursive: true });
|
||||
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
|
||||
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
processesRouter.post('/', async (ctx) => {
|
||||
const { name } = await ctx.req.json<{ name: string }>();
|
||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||
|
||||
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
if (!dirName) return ctx.text('Invalid name', 400);
|
||||
|
||||
const dir = join(getGlobalProcessesDir(), dirName);
|
||||
const filePath = join(dir, 'PROCESS.md');
|
||||
|
||||
if (await Bun.file(filePath).exists()) {
|
||||
return ctx.text('Process already exists', 409);
|
||||
}
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
});
|
||||
|
||||
processesRouter.delete('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const globalDir = join(getGlobalProcessesDir(), name);
|
||||
const globalFile = join(globalDir, 'PROCESS.md');
|
||||
|
||||
if (!(await Bun.file(globalFile).exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
await rm(globalDir, { recursive: true });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
||||
|
||||
const MAX_CONTENT_LENGTH = 100_000;
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
browserPromise = chromium.launch({ headless: true }).catch((err) => {
|
||||
browserPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export const scrapeRouter = createRouter();
|
||||
|
||||
scrapeRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const { url, sessionId, provider } = ctx.get('body') as {
|
||||
url: string;
|
||||
sessionId?: string;
|
||||
provider?: 'claude' | 'opencode';
|
||||
};
|
||||
|
||||
if (!url) return ctx.json({ error: 'url is required' }, 400);
|
||||
|
||||
const browser = await getBrowser();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
|
||||
// Scroll through the page to capture all content, including virtualized/lazy-loaded
|
||||
// content (e.g. ChatGPT shared links remove DOM nodes as you scroll past them).
|
||||
// We capture text incrementally at each scroll position and merge it.
|
||||
const { accumulatedText, scrollableFound } = await page.evaluate(async () => {
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Find the deepest scrollable container (the one that actually scrolls content)
|
||||
let bestScrollable: Element | null = null;
|
||||
let bestOverflow = 0;
|
||||
const elements = Array.from(document.querySelectorAll('*'));
|
||||
for (const el of elements) {
|
||||
const style = getComputedStyle(el);
|
||||
const overflowY = style.overflowY;
|
||||
if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) {
|
||||
const overflow = el.scrollHeight - el.clientHeight;
|
||||
if (overflow > bestOverflow) {
|
||||
bestOverflow = overflow;
|
||||
bestScrollable = el;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrollable = bestScrollable ?? document.scrollingElement;
|
||||
if (!scrollable || scrollable.scrollHeight <= scrollable.clientHeight + 10) {
|
||||
return { accumulatedText: '', scrollableFound: false };
|
||||
}
|
||||
|
||||
// Collect text chunks as we scroll through
|
||||
const seenChunks = new Set<string>();
|
||||
const orderedChunks: string[] = [];
|
||||
|
||||
const captureVisible = () => {
|
||||
const text = document.body.innerText;
|
||||
// Split into paragraphs and capture new ones
|
||||
const paragraphs = text.split(/\n{2,}/);
|
||||
for (const p of paragraphs) {
|
||||
const trimmed = p.trim();
|
||||
if (trimmed && !seenChunks.has(trimmed)) {
|
||||
seenChunks.add(trimmed);
|
||||
orderedChunks.push(trimmed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start from the top
|
||||
scrollable.scrollTop = 0;
|
||||
await delay(500);
|
||||
captureVisible();
|
||||
|
||||
// Scroll incrementally, capturing at each position
|
||||
let stableCount = 0;
|
||||
let lastChunkCount = orderedChunks.length;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
scrollable.scrollTop += scrollable.clientHeight * 0.6;
|
||||
await delay(300);
|
||||
captureVisible();
|
||||
|
||||
// Check if we're at the bottom
|
||||
const atBottom = scrollable.scrollTop + scrollable.clientHeight >= scrollable.scrollHeight - 5;
|
||||
if (atBottom) {
|
||||
// Wait a bit for potential dynamic loading
|
||||
await delay(500);
|
||||
captureVisible();
|
||||
// If no new content appeared, we're done
|
||||
if (orderedChunks.length === lastChunkCount) {
|
||||
stableCount++;
|
||||
if (stableCount >= 2) break;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
lastChunkCount = orderedChunks.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { accumulatedText: orderedChunks.join('\n\n'), scrollableFound: true };
|
||||
});
|
||||
|
||||
const title = await page.title();
|
||||
const html = await page.evaluate(() => document.documentElement.outerHTML);
|
||||
|
||||
// Use accumulated text from scrolling if available, otherwise fall back to current innerText
|
||||
const rawText =
|
||||
scrollableFound && accumulatedText ? accumulatedText : await page.evaluate(() => document.body.innerText);
|
||||
|
||||
// Strip excessive whitespace and truncate
|
||||
const content = rawText
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, MAX_CONTENT_LENGTH);
|
||||
|
||||
// Determine save location
|
||||
const slug = slugify(title || 'page');
|
||||
let attachmentId: string;
|
||||
let saveDir: string;
|
||||
|
||||
if (sessionId && provider) {
|
||||
attachmentId = `${slug}.html`;
|
||||
saveDir = getAttachmentsDir(user.email, provider, sessionId);
|
||||
} else {
|
||||
attachmentId = `${crypto.randomUUID()}.html`;
|
||||
saveDir = getTmpAttachmentsDir(user.email);
|
||||
}
|
||||
|
||||
await mkdir(saveDir, { recursive: true });
|
||||
await Bun.write(join(saveDir, attachmentId), html);
|
||||
|
||||
return ctx.json({ url, title, content, attachmentId });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Scrape failed';
|
||||
return ctx.json({ error: message }, 500);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
type AppSpec = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
versionCommand: string[];
|
||||
versionParser: (output: string) => string | null;
|
||||
installCommand?: string[];
|
||||
updateCommand?: string[];
|
||||
manualInstallCommand?: string;
|
||||
manualUpdateCommand?: string;
|
||||
processName?: string;
|
||||
};
|
||||
|
||||
type AppStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
running: boolean | null;
|
||||
hasInstall: boolean;
|
||||
hasUpdate: boolean;
|
||||
manualInstallCommand: string | null;
|
||||
manualUpdateCommand: string | null;
|
||||
};
|
||||
|
||||
const detectPackageManager = (): 'pacman' | 'apt' | 'brew' | null => {
|
||||
for (const [cmd, name] of [
|
||||
['pacman', 'pacman'],
|
||||
['apt', 'apt'],
|
||||
['brew', 'brew'],
|
||||
] as const) {
|
||||
try {
|
||||
const proc = Bun.spawnSync(['which', cmd], { stdout: 'pipe', stderr: 'pipe' });
|
||||
if (proc.exitCode === 0) return name;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type FfmpegCommands = {
|
||||
installCommand?: string[];
|
||||
updateCommand?: string[];
|
||||
manualInstallCommand?: string;
|
||||
manualUpdateCommand?: string;
|
||||
};
|
||||
|
||||
const getFfmpegCommands = (): FfmpegCommands => {
|
||||
const pm = detectPackageManager();
|
||||
if (pm === 'pacman')
|
||||
return {
|
||||
manualInstallCommand: 'sudo pacman -S ffmpeg',
|
||||
manualUpdateCommand: 'sudo pacman -Syu ffmpeg',
|
||||
};
|
||||
if (pm === 'apt')
|
||||
return {
|
||||
manualInstallCommand: 'sudo apt install ffmpeg',
|
||||
manualUpdateCommand: 'sudo apt install --only-upgrade ffmpeg',
|
||||
};
|
||||
if (pm === 'brew')
|
||||
return {
|
||||
installCommand: ['brew', 'install', 'ffmpeg'],
|
||||
updateCommand: ['brew', 'upgrade', 'ffmpeg'],
|
||||
};
|
||||
return {};
|
||||
};
|
||||
|
||||
const ffmpegCmds = getFfmpegCommands();
|
||||
|
||||
const apps: AppSpec[] = [
|
||||
{
|
||||
id: 'ffmpeg',
|
||||
name: 'FFmpeg',
|
||||
description: 'Audio and video processing toolkit',
|
||||
versionCommand: ['ffmpeg', '-version'],
|
||||
versionParser: (output) => {
|
||||
const match = output.match(/ffmpeg version (\S+)/);
|
||||
return match?.[1] ?? null;
|
||||
},
|
||||
installCommand: ffmpegCmds.installCommand,
|
||||
updateCommand: ffmpegCmds.updateCommand,
|
||||
manualInstallCommand: ffmpegCmds.manualInstallCommand,
|
||||
manualUpdateCommand: ffmpegCmds.manualUpdateCommand,
|
||||
},
|
||||
{
|
||||
id: 'sharp',
|
||||
name: 'Sharp',
|
||||
description: 'High-performance image processing library',
|
||||
versionCommand: ['bun', '--eval', "console.log(require('sharp').versions.sharp)"],
|
||||
versionParser: (output) => output.trim() || null,
|
||||
installCommand: ['bun', 'add', 'sharp'],
|
||||
updateCommand: ['bun', 'add', 'sharp@latest'],
|
||||
},
|
||||
{
|
||||
id: 'whisper-cpp',
|
||||
name: 'Whisper.cpp',
|
||||
description: 'Speech-to-text inference engine',
|
||||
versionCommand: ['whisper-cpp', '--version'],
|
||||
versionParser: (output) => output.trim() || null,
|
||||
installCommand: detectPackageManager() === 'brew' ? ['brew', 'install', 'whisper-cpp'] : undefined,
|
||||
processName: 'whisper-server',
|
||||
},
|
||||
{
|
||||
id: 'mlx-audio',
|
||||
name: 'MLX Audio',
|
||||
description: 'Audio processing with Apple MLX framework',
|
||||
versionCommand: ['pip', 'show', 'mlx-audio'],
|
||||
versionParser: (output) => {
|
||||
const match = output.match(/Version:\s*(\S+)/);
|
||||
return match?.[1] ?? null;
|
||||
},
|
||||
installCommand: ['pip', 'install', 'mlx-audio'],
|
||||
updateCommand: ['pip', 'install', '--upgrade', 'mlx-audio'],
|
||||
},
|
||||
];
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 30_000;
|
||||
|
||||
const runCommand = async (command: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
|
||||
try {
|
||||
const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('Command timed out'));
|
||||
}, COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
const result = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
||||
const [stdout, stderr] = await Promise.race([result, timeout]);
|
||||
return { exitCode: proc.exitCode ?? 1, stdout, stderr };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Command not found or failed to execute';
|
||||
return { exitCode: 1, stdout: '', stderr: message };
|
||||
}
|
||||
};
|
||||
|
||||
const checkVersion = async (app: AppSpec): Promise<string | null> => {
|
||||
const result = await runCommand(app.versionCommand);
|
||||
if (result.exitCode !== 0) return null;
|
||||
return app.versionParser(result.stdout);
|
||||
};
|
||||
|
||||
const checkRunning = async (processName: string): Promise<boolean> => {
|
||||
const result = await runCommand(['pgrep', '-x', processName]);
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
const getAppStatus = async (app: AppSpec): Promise<AppStatus> => {
|
||||
const version = await checkVersion(app);
|
||||
const running = app.processName ? await checkRunning(app.processName) : null;
|
||||
return {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
installed: version !== null,
|
||||
version,
|
||||
running,
|
||||
hasInstall: !!(app.installCommand || app.manualInstallCommand),
|
||||
hasUpdate: !!(app.updateCommand ?? app.installCommand ?? app.manualUpdateCommand ?? app.manualInstallCommand),
|
||||
manualInstallCommand: app.manualInstallCommand ?? null,
|
||||
manualUpdateCommand: app.manualUpdateCommand ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const applicationsRouter = createRouter();
|
||||
|
||||
applicationsRouter.get('/', async (ctx) => {
|
||||
const statuses = await Promise.all(apps.map(getAppStatus));
|
||||
return ctx.json(statuses);
|
||||
});
|
||||
|
||||
applicationsRouter.get('/:id', async (ctx) => {
|
||||
const app = apps.find((a) => a.id === ctx.req.param('id'));
|
||||
if (!app) return ctx.json({ error: 'Application not found' }, 404);
|
||||
const status = await getAppStatus(app);
|
||||
return ctx.json(status);
|
||||
});
|
||||
|
||||
applicationsRouter.post('/:id/install', async (ctx) => {
|
||||
const app = apps.find((a) => a.id === ctx.req.param('id'));
|
||||
if (!app) return ctx.json({ error: 'Application not found' }, 404);
|
||||
if (!app.installCommand) return ctx.json({ error: 'No install command available for this platform' }, 400);
|
||||
|
||||
const result = await runCommand(app.installCommand);
|
||||
if (result.exitCode !== 0) {
|
||||
return ctx.json({ error: result.stderr.trim() || 'Installation failed' }, 500);
|
||||
}
|
||||
|
||||
const status = await getAppStatus(app);
|
||||
return ctx.json(status);
|
||||
});
|
||||
|
||||
applicationsRouter.post('/:id/update', async (ctx) => {
|
||||
const app = apps.find((a) => a.id === ctx.req.param('id'));
|
||||
if (!app) return ctx.json({ error: 'Application not found' }, 404);
|
||||
|
||||
const command = app.updateCommand ?? app.installCommand;
|
||||
if (!command) return ctx.json({ error: 'No update command available for this platform' }, 400);
|
||||
|
||||
const result = await runCommand(command);
|
||||
if (result.exitCode !== 0) {
|
||||
return ctx.json({ error: result.stderr.trim() || 'Update failed' }, 500);
|
||||
}
|
||||
|
||||
const status = await getAppStatus(app);
|
||||
return ctx.json(status);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
export const claudeCodeRouter = createRouter();
|
||||
|
||||
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
|
||||
|
||||
const getPaths = async () => {
|
||||
try {
|
||||
const proc = Bun.spawn(['which', '-a', 'claude'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return { path: null, globalPath: null };
|
||||
const paths = [...new Set(output.trim().split('\n'))];
|
||||
const path = paths[0] ?? null;
|
||||
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
|
||||
return { path, globalPath };
|
||||
} catch {
|
||||
return { path: null, globalPath: null };
|
||||
}
|
||||
};
|
||||
|
||||
claudeCodeRouter.post('/install', async (ctx) => {
|
||||
try {
|
||||
const proc = Bun.spawn(['bash', '-c', 'curl -fsSL https://claude.ai/install.sh | bash'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
|
||||
}
|
||||
const versionProc = Bun.spawn(['claude', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(versionProc.stdout).text();
|
||||
await versionProc.exited;
|
||||
const { path, globalPath } = await getPaths();
|
||||
return ctx.json({ version: output.trim(), path, globalPath });
|
||||
} catch {
|
||||
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
claudeCodeRouter.post('/auth/login', async (ctx) => {
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
Bun.spawn(['claude', 'auth', 'login'], { stdout: 'ignore', stderr: 'ignore', env });
|
||||
return ctx.json({ started: true });
|
||||
} catch {
|
||||
return ctx.json({ started: false, error: 'Failed to start login' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
claudeCodeRouter.get('/auth', async (ctx) => {
|
||||
try {
|
||||
const proc = Bun.spawn(['claude', 'auth', 'status'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return ctx.json({ authenticated: false });
|
||||
const status = JSON.parse(output.trim());
|
||||
return ctx.json({ authenticated: status.loggedIn ?? false, ...status });
|
||||
} catch {
|
||||
return ctx.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
claudeCodeRouter.get('/version', async (ctx) => {
|
||||
try {
|
||||
const proc = Bun.spawn(['claude', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
|
||||
const { path, globalPath } = await getPaths();
|
||||
return ctx.json({ version: output.trim(), path, globalPath });
|
||||
} catch {
|
||||
return ctx.json({ version: null, path: null, globalPath: null });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
export const opencodeRouter = createRouter();
|
||||
|
||||
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
|
||||
|
||||
const getPaths = async () => {
|
||||
try {
|
||||
const proc = Bun.spawn(['which', '-a', 'opencode'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return { path: null, globalPath: null };
|
||||
const paths = [...new Set(output.trim().split('\n'))];
|
||||
const path = paths[0] ?? null;
|
||||
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
|
||||
return { path, globalPath };
|
||||
} catch {
|
||||
return { path: null, globalPath: null };
|
||||
}
|
||||
};
|
||||
|
||||
opencodeRouter.post('/auth/login', async (ctx) => {
|
||||
try {
|
||||
Bun.spawn(['opencode', 'auth', 'login'], { stdout: 'ignore', stderr: 'ignore' });
|
||||
return ctx.json({ started: true });
|
||||
} catch {
|
||||
return ctx.json({ started: false, error: 'Failed to start login' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
opencodeRouter.get('/auth', async (ctx) => {
|
||||
try {
|
||||
const authPath = `${process.env.HOME}/.local/share/opencode/auth.json`;
|
||||
const file = Bun.file(authPath);
|
||||
if (!(await file.exists())) return ctx.json({ authenticated: false, providers: [] });
|
||||
const auth = await file.json();
|
||||
const providers = Object.keys(auth);
|
||||
return ctx.json({ authenticated: providers.length > 0, providers });
|
||||
} catch {
|
||||
return ctx.json({ authenticated: false, providers: [] });
|
||||
}
|
||||
});
|
||||
|
||||
opencodeRouter.post('/install', async (ctx) => {
|
||||
try {
|
||||
const proc = Bun.spawn(['bash', '-c', 'curl -fsSL https://opencode.ai/install | bash'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
|
||||
}
|
||||
const versionProc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(versionProc.stdout).text();
|
||||
await versionProc.exited;
|
||||
const { path, globalPath } = await getPaths();
|
||||
return ctx.json({ version: output.trim(), path, globalPath });
|
||||
} catch {
|
||||
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
opencodeRouter.get('/version', async (ctx) => {
|
||||
try {
|
||||
const proc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
|
||||
const { path, globalPath } = await getPaths();
|
||||
return ctx.json({ version: output.trim(), path, globalPath });
|
||||
} catch {
|
||||
return ctx.json({ version: null, path: null, globalPath: null });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { officerdb, count, Users } from 'officerdb';
|
||||
import { claudeCodeRouter } from './claude-code';
|
||||
import { opencodeRouter } from './opencode';
|
||||
import { applicationsRouter } from './applications';
|
||||
|
||||
const configDir = `${homedir()}/.config/officer.dev`;
|
||||
export const settingsPath = `${configDir}/server-settings.json`;
|
||||
|
||||
const settingsFile = Bun.file(settingsPath);
|
||||
if (!(await settingsFile.exists())) {
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await Bun.write(settingsPath, '{}');
|
||||
}
|
||||
|
||||
export const serverSettingsRouter = createRouter();
|
||||
|
||||
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
|
||||
serverSettingsRouter.route('/opencode', opencodeRouter);
|
||||
serverSettingsRouter.route('/applications', applicationsRouter);
|
||||
|
||||
serverSettingsRouter.get('/', async (ctx) => {
|
||||
const result = await officerdb.select({ count: count() }).from(Users);
|
||||
const userCount = result[0]?.count ?? 0;
|
||||
return ctx.json({ registrationOpen: userCount === 0 });
|
||||
});
|
||||
|
||||
serverSettingsRouter.get('/settings', async (ctx) => {
|
||||
const settings = await Bun.file(settingsPath).json();
|
||||
return ctx.json(settings);
|
||||
});
|
||||
|
||||
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
|
||||
const settings = await Bun.file(settingsPath).json();
|
||||
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
||||
});
|
||||
|
||||
serverSettingsRouter.put('/', async (ctx) => {
|
||||
const body = await ctx.req.json();
|
||||
const settings = await Bun.file(settingsPath).json();
|
||||
const updated = { ...settings, ...body };
|
||||
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
|
||||
return ctx.json(updated);
|
||||
});
|
||||
|
||||
serverSettingsRouter.get('/plugins', async (ctx) => {
|
||||
const pluginsDir = join(import.meta.dir, '../../../workspaces/plugins');
|
||||
const settings = await Bun.file(settingsPath)
|
||||
.json()
|
||||
.catch(() => ({}));
|
||||
const pluginSettings: Record<string, boolean> = settings.plugins ?? {};
|
||||
|
||||
const plugins: { id: string; name: string; description: string; enabled: boolean }[] = [];
|
||||
|
||||
if (!existsSync(pluginsDir)) return ctx.json(plugins);
|
||||
|
||||
const dirs = readdirSync(pluginsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
||||
|
||||
for (const dir of dirs) {
|
||||
const hasServer = existsSync(join(pluginsDir, dir.name, 'server', 'index.ts'));
|
||||
const hasClient = existsSync(join(pluginsDir, dir.name, 'client', 'index.ts'));
|
||||
if (!hasServer && !hasClient) continue;
|
||||
|
||||
const mainIndex = join(pluginsDir, dir.name, 'index.ts');
|
||||
if (!existsSync(mainIndex)) continue;
|
||||
|
||||
const mod = await import(mainIndex);
|
||||
const meta = mod.plugin ?? { id: dir.name, name: dir.name, description: '' };
|
||||
plugins.push({
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
description: meta.description,
|
||||
enabled: pluginSettings[meta.id] !== false,
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json(plugins);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { getUserSettingsFile, getUserStateFile } from '@@/data-path';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
chat: {
|
||||
defaultProvider: 'claude',
|
||||
defaultModel: null,
|
||||
systemPrompt: '',
|
||||
temperature: 1,
|
||||
defaultPwd: '~',
|
||||
},
|
||||
appearance: {
|
||||
theme: 'light',
|
||||
},
|
||||
};
|
||||
|
||||
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
|
||||
|
||||
export const settingsRouter = createRouter();
|
||||
|
||||
// GET /settings — return settings.json, auto-create with defaults if missing
|
||||
settingsRouter.get('/settings', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const filePath = getUserSettingsFile(email);
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
const data = await file.json();
|
||||
return ctx.json(data);
|
||||
}
|
||||
|
||||
await ensureDir(filePath);
|
||||
await Bun.write(file, JSON.stringify(DEFAULT_SETTINGS, null, 2));
|
||||
return ctx.json(DEFAULT_SETTINGS);
|
||||
});
|
||||
|
||||
// PUT /settings — full replacement
|
||||
settingsRouter.put('/settings', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const body = ctx.get('body');
|
||||
const filePath = getUserSettingsFile(email);
|
||||
|
||||
await ensureDir(filePath);
|
||||
await Bun.write(filePath, JSON.stringify(body, null, 2));
|
||||
return ctx.json(body);
|
||||
});
|
||||
|
||||
// GET /state — return state.json, auto-create with {} if missing
|
||||
settingsRouter.get('/state', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const filePath = getUserStateFile(email);
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
const data = await file.json();
|
||||
return ctx.json(data);
|
||||
}
|
||||
|
||||
await ensureDir(filePath);
|
||||
await Bun.write(file, JSON.stringify({}, null, 2));
|
||||
return ctx.json({});
|
||||
});
|
||||
|
||||
// PATCH /state — shallow-merge incoming keys
|
||||
settingsRouter.patch('/state', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const body = ctx.get('body');
|
||||
const filePath = getUserStateFile(email);
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (await file.exists()) {
|
||||
existing = await file.json();
|
||||
}
|
||||
|
||||
const merged = { ...existing, ...body };
|
||||
await ensureDir(filePath);
|
||||
await Bun.write(filePath, JSON.stringify(merged, null, 2));
|
||||
return ctx.json(merged);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readdir, mkdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '../../data-path';
|
||||
|
||||
type Frontmatter = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: { name: '', description: '' }, body: raw };
|
||||
|
||||
const yaml = match[1]!;
|
||||
const body = match[2]!;
|
||||
|
||||
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
|
||||
return { frontmatter: { name, description }, body };
|
||||
}
|
||||
|
||||
export async function readSkillDirs(dir: string): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillFile = join(dir, entry.name, 'SKILL.md');
|
||||
if (await Bun.file(skillFile).exists()) {
|
||||
result.set(entry.name, skillFile);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// directory doesn't exist yet
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
type Scope = 'native' | 'global' | 'user';
|
||||
|
||||
function resolveScope(dirName: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): Scope {
|
||||
if (user.has(dirName)) return 'user';
|
||||
if (global.has(dirName)) return 'global';
|
||||
return 'native';
|
||||
}
|
||||
|
||||
function resolveFile(name: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): { filePath: string; scope: Scope } | null {
|
||||
if (user.has(name)) return { filePath: user.get(name)!, scope: 'user' };
|
||||
if (global.has(name)) return { filePath: global.get(name)!, scope: 'global' };
|
||||
if (native.has(name)) return { filePath: native.get(name)!, scope: 'native' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const skillsRouter = createRouter();
|
||||
|
||||
skillsRouter.get('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
|
||||
|
||||
const merged = new Map(nativeSkills);
|
||||
for (const [name, path] of globalSkills) merged.set(name, path);
|
||||
for (const [name, path] of userSkills) merged.set(name, path);
|
||||
|
||||
const skills = await Promise.all(
|
||||
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter } = parseFrontmatter(raw);
|
||||
const scope = resolveScope(dirName, nativeSkills, globalSkills, userSkills);
|
||||
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope };
|
||||
}),
|
||||
);
|
||||
|
||||
return ctx.json(skills);
|
||||
});
|
||||
|
||||
skillsRouter.get('/:name', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const raw = await Bun.file(resolved.filePath).text();
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
|
||||
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
|
||||
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
|
||||
return ctx.json({
|
||||
name: frontmatter.name || name,
|
||||
description: frontmatter.description,
|
||||
scope: resolved.scope,
|
||||
body,
|
||||
filePath: resolved.filePath,
|
||||
chatSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
skillsRouter.get('/:name/chat', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
|
||||
|
||||
return ctx.json({ sessionId, messages });
|
||||
});
|
||||
|
||||
skillsRouter.put('/:name/chat', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
await mkdir(chatDir, { recursive: true });
|
||||
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
|
||||
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
skillsRouter.post('/', async (ctx) => {
|
||||
const { name } = await ctx.req.json<{ name: string }>();
|
||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||
|
||||
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
if (!dirName) return ctx.text('Invalid name', 400);
|
||||
|
||||
const dir = join(getGlobalSkillsDir(), dirName);
|
||||
const filePath = join(dir, 'SKILL.md');
|
||||
|
||||
if (await Bun.file(filePath).exists()) {
|
||||
return ctx.text('Skill already exists', 409);
|
||||
}
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
});
|
||||
|
||||
skillsRouter.delete('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const globalDir = join(getGlobalSkillsDir(), name);
|
||||
const globalFile = join(globalDir, 'SKILL.md');
|
||||
|
||||
if (!(await Bun.file(globalFile).exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
await rm(globalDir, { recursive: true });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getTaskLogsDir } from '@@/data-path';
|
||||
import type { TaskInfo } from '@@/api/chat-types';
|
||||
|
||||
type ChatMessage =
|
||||
| { role: 'user'; text: string }
|
||||
| { role: 'assistant'; text: string }
|
||||
| {
|
||||
role: 'tool';
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
toolUseId: string;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
| { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
|
||||
| { role: 'error'; text: string };
|
||||
|
||||
type TaskLog = {
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
provider: string;
|
||||
model: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
email: string;
|
||||
filePath: string;
|
||||
log: TaskLog;
|
||||
};
|
||||
|
||||
const activeLogs = new Map<string, LogEntry>();
|
||||
let logCounter = 0;
|
||||
|
||||
export function createTaskLog(email: string, taskInfo: TaskInfo, provider: string, model: string): string {
|
||||
const logId = `log_${Date.now()}_${++logCounter}`;
|
||||
const dir = getTaskLogsDir(email);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${timestamp}-${taskInfo.taskDirName}.json`;
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
const log: TaskLog = {
|
||||
taskName: taskInfo.taskName,
|
||||
taskDirName: taskInfo.taskDirName,
|
||||
entryName: taskInfo.entryName,
|
||||
entryType: taskInfo.entryType,
|
||||
provider,
|
||||
model,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
activeLogs.set(logId, { email, filePath, log });
|
||||
return logId;
|
||||
}
|
||||
|
||||
export function appendToLog(logId: string, message: ChatMessage) {
|
||||
const entry = activeLogs.get(logId);
|
||||
if (!entry) return;
|
||||
|
||||
// For tool results, update the existing tool message instead of appending
|
||||
if (message.role === 'tool' && message.output !== undefined) {
|
||||
const existing = entry.log.messages.find((m) => m.role === 'tool' && m.toolUseId === message.toolUseId);
|
||||
if (existing && existing.role === 'tool') {
|
||||
existing.output = message.output;
|
||||
existing.isError = message.isError;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
entry.log.messages.push(message);
|
||||
}
|
||||
|
||||
export async function finalizeLog(logId: string) {
|
||||
const entry = activeLogs.get(logId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.log.completedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await mkdir(join(entry.filePath, '..'), { recursive: true });
|
||||
await Bun.write(entry.filePath, JSON.stringify(entry.log, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[task-logger] Failed to write log:', err);
|
||||
}
|
||||
|
||||
activeLogs.delete(logId);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getTaskLogsDir } from '../../data-path';
|
||||
|
||||
type LogMetadata = {
|
||||
filename: string;
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
provider: string;
|
||||
model: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export const taskLogsRouter = createRouter();
|
||||
|
||||
// GET / — list all log files (metadata only, no messages)
|
||||
taskLogsRouter.get('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const dir = getTaskLogsDir(email);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await readdir(dir)).filter((f) => f.endsWith('.json'));
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
|
||||
// Sort by filename descending (newest first since filenames start with timestamp)
|
||||
files.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const logs: LogMetadata[] = [];
|
||||
for (const filename of files) {
|
||||
try {
|
||||
const raw = await Bun.file(join(dir, filename)).json();
|
||||
const lastMessage = Array.isArray(raw.messages) ? raw.messages[raw.messages.length - 1] : null;
|
||||
const isError = lastMessage?.role === 'result' ? lastMessage.isError : lastMessage?.role === 'error';
|
||||
logs.push({
|
||||
filename,
|
||||
taskName: raw.taskName ?? '',
|
||||
taskDirName: raw.taskDirName ?? '',
|
||||
entryName: raw.entryName ?? '',
|
||||
entryType: raw.entryType ?? 'file',
|
||||
provider: raw.provider ?? '',
|
||||
model: raw.model ?? '',
|
||||
startedAt: raw.startedAt ?? '',
|
||||
completedAt: raw.completedAt ?? null,
|
||||
isError: !!isError,
|
||||
});
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(logs);
|
||||
});
|
||||
|
||||
// GET /:filename — return full log file content
|
||||
taskLogsRouter.get('/:filename', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const filename = ctx.req.param('filename');
|
||||
|
||||
if (!filename.endsWith('.json') || filename.includes('/') || filename.includes('..')) {
|
||||
return ctx.text('Invalid filename', 400);
|
||||
}
|
||||
|
||||
const filePath = join(getTaskLogsDir(email), filename);
|
||||
|
||||
try {
|
||||
const data = await Bun.file(filePath).json();
|
||||
return ctx.json(data);
|
||||
} catch {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readdir, mkdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { getNativeTasksDir, getGlobalTasksDir, getUserTasksDir } from '../../data-path';
|
||||
|
||||
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
||||
|
||||
type Frontmatter = {
|
||||
name: string;
|
||||
description: string;
|
||||
triggers: TriggerConfig[];
|
||||
};
|
||||
|
||||
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: { name: '', description: '', triggers: [] }, body: raw };
|
||||
|
||||
const yaml = match[1]!;
|
||||
const body = match[2]!;
|
||||
|
||||
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
|
||||
const triggers: TriggerConfig[] = [];
|
||||
const triggerMatch = yaml.match(/^trigger:\s*\n((?:[ \t]+.+\n?)*)/m);
|
||||
if (triggerMatch) {
|
||||
// Split on top-level list items (lines starting with " - type:")
|
||||
const items = triggerMatch[1]!.split(/(?=^\s+-\s*type:)/m);
|
||||
for (const item of items) {
|
||||
const type = item.match(/type:\s*(.+)/)?.[1]?.trim();
|
||||
if (type === 'directory') {
|
||||
triggers.push({ type: 'directory' });
|
||||
} else if (type === 'file') {
|
||||
const extBlock = item.match(/extensions:\s*\n((?:\s+-\s*.+\n?)*)/);
|
||||
const extensions = extBlock ? [...extBlock[1]!.matchAll(/^\s+-\s*(.+)$/gm)].map((m) => m[1]!.trim()) : [];
|
||||
if (extensions.length > 0) triggers.push({ type: 'file', extensions });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter: { name, description, triggers }, body };
|
||||
}
|
||||
|
||||
export async function readTaskDirs(dir: string): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const taskFile = join(dir, entry.name, 'TASK.md');
|
||||
if (await Bun.file(taskFile).exists()) {
|
||||
result.set(entry.name, taskFile);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// directory doesn't exist yet
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
type Scope = 'native' | 'global' | 'user';
|
||||
|
||||
function resolveScope(dirName: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): Scope {
|
||||
if (user.has(dirName)) return 'user';
|
||||
if (global.has(dirName)) return 'global';
|
||||
return 'native';
|
||||
}
|
||||
|
||||
function resolveFile(name: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): { filePath: string; scope: Scope } | null {
|
||||
if (user.has(name)) return { filePath: user.get(name)!, scope: 'user' };
|
||||
if (global.has(name)) return { filePath: global.get(name)!, scope: 'global' };
|
||||
if (native.has(name)) return { filePath: native.get(name)!, scope: 'native' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const tasksRouter = createRouter();
|
||||
|
||||
tasksRouter.get('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const nativeTasks = await readTaskDirs(getNativeTasksDir());
|
||||
const globalTasks = await readTaskDirs(getGlobalTasksDir());
|
||||
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
|
||||
|
||||
const merged = new Map(nativeTasks);
|
||||
for (const [name, path] of globalTasks) merged.set(name, path);
|
||||
for (const [name, path] of userTasks) merged.set(name, path);
|
||||
|
||||
const tasks = await Promise.all(
|
||||
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter } = parseFrontmatter(raw);
|
||||
const scope = resolveScope(dirName, nativeTasks, globalTasks, userTasks);
|
||||
return {
|
||||
dirName,
|
||||
name: frontmatter.name || dirName,
|
||||
description: frontmatter.description,
|
||||
scope,
|
||||
triggers: frontmatter.triggers,
|
||||
filePath,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return ctx.json(tasks);
|
||||
});
|
||||
|
||||
tasksRouter.get('/:name', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeTasks = await readTaskDirs(getNativeTasksDir());
|
||||
const globalTasks = await readTaskDirs(getGlobalTasksDir());
|
||||
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const raw = await Bun.file(resolved.filePath).text();
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
|
||||
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
|
||||
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
|
||||
return ctx.json({
|
||||
name: frontmatter.name || name,
|
||||
description: frontmatter.description,
|
||||
scope: resolved.scope,
|
||||
body,
|
||||
filePath: resolved.filePath,
|
||||
chatSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
tasksRouter.get('/:name/chat', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeTasks = await readTaskDirs(getNativeTasksDir());
|
||||
const globalTasks = await readTaskDirs(getGlobalTasksDir());
|
||||
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
|
||||
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
|
||||
|
||||
return ctx.json({ sessionId, messages });
|
||||
});
|
||||
|
||||
tasksRouter.put('/:name/chat', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
|
||||
const nativeTasks = await readTaskDirs(getNativeTasksDir());
|
||||
const globalTasks = await readTaskDirs(getGlobalTasksDir());
|
||||
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
|
||||
|
||||
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
await mkdir(chatDir, { recursive: true });
|
||||
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
|
||||
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
tasksRouter.post('/', async (ctx) => {
|
||||
const { name } = await ctx.req.json<{ name: string }>();
|
||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||
|
||||
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
if (!dirName) return ctx.text('Invalid name', 400);
|
||||
|
||||
const dir = join(getGlobalTasksDir(), dirName);
|
||||
const filePath = join(dir, 'TASK.md');
|
||||
|
||||
if (await Bun.file(filePath).exists()) {
|
||||
return ctx.text('Task already exists', 409);
|
||||
}
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
});
|
||||
|
||||
tasksRouter.delete('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const globalDir = join(getGlobalTasksDir(), name);
|
||||
const globalFile = join(globalDir, 'TASK.md');
|
||||
|
||||
if (!(await Bun.file(globalFile).exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
await rm(globalDir, { recursive: true });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
export const uploadRouter = createRouter();
|
||||
|
||||
uploadRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
|
||||
const file = body.file as File | null;
|
||||
const sessionId = (body.sessionId as string) || null;
|
||||
const provider = (body.provider as 'claude' | 'opencode') || null;
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return ctx.json({ error: 'file is required' }, 400);
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return ctx.json({ error: 'Only image files are allowed' }, 400);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return ctx.json({ error: 'File exceeds 5 MB limit' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const ext = file.name.split('.').pop() || 'png';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
|
||||
let saveDir: string;
|
||||
if (sessionId && provider) {
|
||||
saveDir = getAttachmentsDir(user.email, provider, sessionId);
|
||||
} else {
|
||||
saveDir = getTmpAttachmentsDir(user.email);
|
||||
}
|
||||
|
||||
await mkdir(saveDir, { recursive: true });
|
||||
await Bun.write(join(saveDir, filename), buffer);
|
||||
|
||||
const base64 = buffer.toString('base64');
|
||||
const dataUrl = `data:${file.type};base64,${base64}`;
|
||||
|
||||
return ctx.json({ filename: file.name, dataUrl, attachmentId: filename });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import * as errors from '@@/custom-errors';
|
||||
|
||||
export const updateUserHandler: Handler = async function (ctx) {
|
||||
const { name, avatar } = ctx.get('body');
|
||||
const reqUser = ctx.get('user');
|
||||
|
||||
if (typeof name !== 'string') throw errors.BAD_REQUEST('Name is required');
|
||||
|
||||
await officerdb
|
||||
.update(Users)
|
||||
.set({ name, avatar: avatar ?? null })
|
||||
.where(eq(Users.id, reqUser.id));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
};
|
||||
Reference in New Issue
Block a user