feat: add initial impl of better-auth

pull/715/head
Travis Fischer 2025-05-23 00:19:04 +07:00
rodzic cfff2d37fc
commit 8a5a9b421e
30 zmienionych plików z 719 dodań i 417 usunięć

37
.vscode/launch.json vendored 100644
Wyświetl plik

@ -0,0 +1,37 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug API",
"type": "node",
"request": "launch",
// Debug server in VSCode
"cwd": "${workspaceFolder}/apps/api",
"program": "src/server.ts",
// "program": "${file}",
/*
* Path to tsx binary
* Assuming locally installed
*/
"runtimeExecutable": "tsx",
/*
* Open terminal when debugging starts (Optional)
* Useful to see console.logs
*/
"console": "integratedTerminal",
"internalConsoleOptions": "openOnFirstSessionStart",
// Files to exclude from debugger (e.g. call stack)
"skipFiles": [
// Node.js internal core modules
"<node_internals>/**"
// Ignore all dependencies (optional)
// "${workspaceFolder}/node_modules/**"
]
}
]
}

Wyświetl plik

@ -1,20 +0,0 @@
import { config } from '@fisch0920/config/eslint'
import drizzle from 'eslint-plugin-drizzle'
export default [
...config,
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
drizzle
},
rules: {
...drizzle.configs.recommended.rules,
'no-console': 'error',
'unicorn/no-array-reduce': 'off'
}
},
{
ignores: ['**/out/**']
}
]

Wyświetl plik

@ -1,7 +1,7 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import { fromError } from 'zod-validation-error'
import type { AuthenticatedEnv } from '@/lib/types'
import type { AuthenticatedEnv, DefaultContext } from '@/lib/types'
import { auth } from '@/lib/auth'
import * as middleware from '@/lib/middleware'
import { registerOpenAPIErrorResponses } from '@/lib/openapi-utils'
@ -18,7 +18,6 @@ import { registerV1DeploymentsListDeployments } from './deployments/list-deploym
import { registerV1DeploymentsPublishDeployment } from './deployments/publish-deployment'
import { registerV1DeploymentsUpdateDeployment } from './deployments/update-deployment'
import { registerHealthCheck } from './health-check'
// import { registerV1OAuthRedirect } from './oauth-redirect'
import { registerV1ProjectsCreateProject } from './projects/create-project'
import { registerV1ProjectsGetProject } from './projects/get-project'
import { registerV1ProjectsListProjects } from './projects/list-projects'
@ -31,8 +30,8 @@ import { registerV1TeamsMembersCreateTeamMember } from './teams/members/create-t
import { registerV1TeamsMembersDeleteTeamMember } from './teams/members/delete-team-member'
import { registerV1TeamsMembersUpdateTeamMember } from './teams/members/update-team-member'
import { registerV1TeamsUpdateTeam } from './teams/update-team'
// import { registerV1UsersGetUser } from './users/get-user'
// import { registerV1UsersUpdateUser } from './users/update-user'
import { registerV1UsersGetUser } from './users/get-user'
import { registerV1UsersUpdateUser } from './users/update-user'
import { registerV1StripeWebhook } from './webhooks/stripe-webhook'
export const apiV1 = new OpenAPIHono({
@ -65,8 +64,8 @@ const privateRouter = new OpenAPIHono<AuthenticatedEnv>()
registerHealthCheck(publicRouter)
// Users
// registerV1UsersGetUser(privateRouter)
// registerV1UsersUpdateUser(privateRouter)
registerV1UsersGetUser(privateRouter)
registerV1UsersUpdateUser(privateRouter)
// Teams
registerV1TeamsCreateTeam(privateRouter)
@ -106,10 +105,15 @@ registerV1AdminConsumersGetConsumerByToken(privateRouter)
// Webhook event handlers
registerV1StripeWebhook(publicRouter)
// OAuth redirect
// registerV1OAuthRedirect(publicRouter)
// Better-Auth Handler for all auth-related routes
apiV1.on(['POST', 'GET'], 'auth/**', async (c: DefaultContext) => {
const logger = c.get('logger')
logger.info(c.req.method, c.req.url, c.req.header())
publicRouter.on(['POST', 'GET'], 'auth/**', (c) => auth.handler(c.req.raw))
const res = await auth.handler(c.req.raw)
logger.info('auth result', res)
return res
})
// Setup routes and middleware
apiV1.route('/', publicRouter)

Wyświetl plik

@ -1,51 +1,51 @@
// import { assert, parseZodSchema } from '@agentic/platform-core'
// import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import { assert, parseZodSchema } from '@agentic/platform-core'
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
// import type { AuthenticatedEnv } from '@/lib/types'
// import { db, eq, schema } from '@/db'
// import { acl } from '@/lib/acl'
// import {
// openapiAuthenticatedSecuritySchemas,
// openapiErrorResponse404,
// openapiErrorResponses
// } from '@/lib/openapi-utils'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponses
} from '@/lib/openapi-utils'
// import { userIdParamsSchema } from './schemas'
import { userIdParamsSchema } from './schemas'
// const route = createRoute({
// description: 'Gets a user',
// tags: ['users'],
// operationId: 'getUser',
// method: 'get',
// path: 'users/{userId}',
// security: openapiAuthenticatedSecuritySchemas,
// request: {
// params: userIdParamsSchema
// },
// responses: {
// 200: {
// description: 'A user object',
// content: {
// 'application/json': {
// schema: schema.userSelectSchema
// }
// }
// },
// ...openapiErrorResponses,
// ...openapiErrorResponse404
// }
// })
const route = createRoute({
description: 'Gets a user',
tags: ['users'],
operationId: 'getUser',
method: 'get',
path: 'users/{userId}',
security: openapiAuthenticatedSecuritySchemas,
request: {
params: userIdParamsSchema
},
responses: {
200: {
description: 'A user object',
content: {
'application/json': {
schema: schema.userSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404
}
})
// export function registerV1UsersGetUser(app: OpenAPIHono<AuthenticatedEnv>) {
// return app.openapi(route, async (c) => {
// const { userId } = c.req.valid('param')
// await acl(c, { userId }, { label: 'User' })
export function registerV1UsersGetUser(app: OpenAPIHono<AuthenticatedEnv>) {
return app.openapi(route, async (c) => {
const { userId } = c.req.valid('param')
await acl(c, { userId }, { label: 'User' })
// const user = await db.query.users.findFirst({
// where: eq(schema.users.id, userId)
// })
// assert(user, 404, `User not found "${userId}"`)
const user = await db.query.users.findFirst({
where: eq(schema.users.id, userId)
})
assert(user, 404, `User not found "${userId}"`)
// return c.json(parseZodSchema(schema.userSelectSchema, user))
// })
// }
return c.json(parseZodSchema(schema.userSelectSchema, user))
})
}

Wyświetl plik

@ -1,62 +1,62 @@
// import { assert, parseZodSchema } from '@agentic/platform-core'
// import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import { assert, parseZodSchema } from '@agentic/platform-core'
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
// import type { AuthenticatedEnv } from '@/lib/types'
// import { db, eq, schema } from '@/db'
// import { acl } from '@/lib/acl'
// import {
// openapiAuthenticatedSecuritySchemas,
// openapiErrorResponse404,
// openapiErrorResponses
// } from '@/lib/openapi-utils'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponses
} from '@/lib/openapi-utils'
// import { userIdParamsSchema } from './schemas'
import { userIdParamsSchema } from './schemas'
// const route = createRoute({
// description: 'Updates a user',
// tags: ['users'],
// operationId: 'updateUser',
// method: 'post',
// path: 'users/{userId}',
// security: openapiAuthenticatedSecuritySchemas,
// request: {
// params: userIdParamsSchema,
// body: {
// required: true,
// content: {
// 'application/json': {
// schema: schema.userUpdateSchema
// }
// }
// }
// },
// responses: {
// 200: {
// description: 'A user object',
// content: {
// 'application/json': {
// schema: schema.userSelectSchema
// }
// }
// },
// ...openapiErrorResponses,
// ...openapiErrorResponse404
// }
// })
const route = createRoute({
description: 'Updates a user',
tags: ['users'],
operationId: 'updateUser',
method: 'post',
path: 'users/{userId}',
security: openapiAuthenticatedSecuritySchemas,
request: {
params: userIdParamsSchema,
body: {
required: true,
content: {
'application/json': {
schema: schema.userUpdateSchema
}
}
}
},
responses: {
200: {
description: 'A user object',
content: {
'application/json': {
schema: schema.userSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404
}
})
// export function registerV1UsersUpdateUser(app: OpenAPIHono<AuthenticatedEnv>) {
// return app.openapi(route, async (c) => {
// const { userId } = c.req.valid('param')
// await acl(c, { userId }, { label: 'User' })
// const body = c.req.valid('json')
export function registerV1UsersUpdateUser(app: OpenAPIHono<AuthenticatedEnv>) {
return app.openapi(route, async (c) => {
const { userId } = c.req.valid('param')
await acl(c, { userId }, { label: 'User' })
const body = c.req.valid('json')
// const [user] = await db
// .update(schema.users)
// .set(body)
// .where(eq(schema.users.id, userId))
// .returning()
// assert(user, 404, `User not found "${userId}"`)
const [user] = await db
.update(schema.users)
.set(body)
.where(eq(schema.users.id, userId))
.returning()
assert(user, 404, `User not found "${userId}"`)
// return c.json(parseZodSchema(schema.userSelectSchema, user))
// })
// }
return c.json(parseZodSchema(schema.userSelectSchema, user))
})
}

Wyświetl plik

@ -2,8 +2,8 @@ import { pgTable, text, timestamp } from '@fisch0920/drizzle-orm/pg-core'
import {
accountPrimaryId,
authTimestamps,
sessionPrimaryId,
timestamps,
userId,
verificationPrimaryId
} from './common'
@ -13,38 +13,41 @@ import { users } from './user'
export const sessions = pgTable('sessions', {
...sessionPrimaryId,
...timestamps,
...authTimestamps,
expiresAt: timestamp('expiresAt').notNull(),
ipAddress: text('ipAddress'),
userAgent: text('userAgent'),
token: text().notNull().unique(),
expiresAt: timestamp({ mode: 'date' }).notNull(),
ipAddress: text(),
userAgent: text(),
userId: userId()
.notNull()
.references(() => users.id)
.references(() => users.id, { onDelete: 'cascade' })
})
export const accounts = pgTable('accounts', {
...accountPrimaryId,
...timestamps,
...authTimestamps,
accountId: text('accountId').notNull(),
providerId: text('providerId').notNull(),
accountId: text().notNull(),
providerId: text().notNull(),
userId: userId()
.notNull()
.references(() => users.id),
accessToken: text('accessToken'),
refreshToken: text('refreshToken'),
idToken: text('idToken'),
expiresAt: timestamp('expiresAt').notNull(),
password: text('password')
.references(() => users.id, { onDelete: 'cascade' }),
accessToken: text(),
refreshToken: text(),
accessTokenExpiresAt: timestamp({ mode: 'date' }),
refreshTokenExpiresAt: timestamp({ mode: 'date' }),
scope: text(),
idToken: text(),
password: text()
})
export const verifications = pgTable('verifications', {
...verificationPrimaryId,
...timestamps,
...authTimestamps,
identifier: text('identifier').notNull(),
value: text('value').notNull(),
identifier: text().notNull(),
value: text().notNull(),
expiresAt: timestamp('expiresAt').notNull()
expiresAt: timestamp({ mode: 'date' }).notNull()
})

Wyświetl plik

@ -157,6 +157,15 @@ export const timestamps = {
deletedAt: timestamp()
}
export const authTimestamps = {
createdAt: timestamp({ mode: 'date' })
.notNull()
.$defaultFn(() => /* @__PURE__ */ new Date()),
updatedAt: timestamp({ mode: 'date' })
.notNull()
.$defaultFn(() => /* @__PURE__ */ new Date())
}
export const userRoleEnum = pgEnum('UserRole', ['user', 'admin'])
export const teamMemberRoleEnum = pgEnum('TeamMemberRole', ['user', 'admin'])
export const logEntryTypeEnum = pgEnum('LogEntryType', ['log'])

Wyświetl plik

@ -1,4 +1,3 @@
import { relations } from '@fisch0920/drizzle-orm'
import {
boolean,
index,
@ -8,15 +7,15 @@ import {
} from '@fisch0920/drizzle-orm/pg-core'
import {
authTimestamps,
createSelectSchema,
createUpdateSchema,
stripeId,
timestamps,
username,
// username,
userPrimaryId,
userRoleEnum
} from './common'
import { teams } from './team'
// This table is mostly managed by better-auth.
@ -24,15 +23,15 @@ export const users = pgTable(
'users',
{
...userPrimaryId,
...timestamps,
...authTimestamps,
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('emailVerified').default(false).notNull(),
image: text('image'),
name: text().notNull(),
email: text().notNull().unique(),
emailVerified: boolean().default(false).notNull(),
image: text(),
// TODO: re-add username
username: username(),
username: username().unique(),
role: userRoleEnum().default('user').notNull(),
isStripeConnectEnabledByDefault: boolean().default(true).notNull(),
@ -41,61 +40,21 @@ export const users = pgTable(
},
(table) => [
uniqueIndex('user_email_idx').on(table.email),
// uniqueIndex('user_username_idx').on(table.username),
uniqueIndex('user_username_idx').on(table.username),
index('user_createdAt_idx').on(table.createdAt),
index('user_updatedAt_idx').on(table.updatedAt),
index('user_deletedAt_idx').on(table.deletedAt)
index('user_updatedAt_idx').on(table.updatedAt)
// index('user_deletedAt_idx').on(table.deletedAt)
]
)
export const usersRelations = relations(users, ({ many }) => ({
teamsOwned: many(teams)
}))
export const userSelectSchema = createSelectSchema(users, {
// authProviders: publicAuthProvidersSchema
})
// .omit({ password: true, emailConfirmToken: true, passwordResetToken: true })
export const userSelectSchema = createSelectSchema(users)
.strip()
.openapi('User')
// export const userInsertSchema = createInsertSchema(users, {
// username: (schema) =>
// schema.refine((username) => validators.username(username), {
// message: 'Invalid username'
// }),
// email: (schema) => schema.email().optional()
// })
// .pick({
// username: true,
// email: true,
// password: true,
// firstName: true,
// lastName: true,
// image: true
// })
// .strict()
// .transform((user) => {
// return {
// ...user,
// emailConfirmToken: sha256(),
// password: user.password ? hashSync(user.password) : undefined
// }
// })
// export const userUpdateSchema = createUpdateSchema(users)
// .pick({
// firstName: true,
// lastName: true,
// image: true,
// password: true,
// isStripeConnectEnabledByDefault: true
// })
// .strict()
// .transform((user) => {
// return {
// ...user,
// password: user.password ? hashSync(user.password) : undefined
// }
// })
export const userUpdateSchema = createUpdateSchema(users)
.pick({
name: true,
image: true,
isStripeConnectEnabledByDefault: true
})
.strict()

Wyświetl plik

@ -8,9 +8,12 @@ import { createIdForModel, db } from '@/db'
import { env } from './env'
export const auth = betterAuth({
adapter: drizzleAdapter(db, {
appName: 'Agentic',
basePath: '/v1/auth',
database: drizzleAdapter(db, {
provider: 'pg'
}),
trustedOrigins: ['http://localhost:6013'],
emailAndPassword: {
enabled: true
},
@ -40,10 +43,18 @@ export const auth = betterAuth({
}
},
session: {
modelName: 'sessions'
modelName: 'sessions',
cookieCache: {
enabled: true,
maxAge: 10 * 60 // 10 minutes in seconds
}
},
account: {
modelName: 'accounts'
modelName: 'accounts',
accountLinking: {
enabled: true,
trustedProviders: ['github']
}
},
verification: {
modelName: 'verifications'

Wyświetl plik

@ -65,7 +65,11 @@ export class ConsoleLogger implements Logger {
return
}
this.console.trace(this._marshal('trace', message, ...detail))
if (this.environment === 'development') {
this.console.trace(message, ...detail)
} else {
this.console.trace(this._marshal('trace', message, ...detail))
}
}
debug(message?: any, ...detail: any[]) {
@ -73,7 +77,11 @@ export class ConsoleLogger implements Logger {
return
}
this.console.debug(this._marshal('debug', message, ...detail))
if (this.environment === 'development') {
this.console.debug(message, ...detail)
} else {
this.console.debug(this._marshal('debug', message, ...detail))
}
}
info(message?: any, ...detail: any[]) {
@ -81,7 +89,11 @@ export class ConsoleLogger implements Logger {
return
}
this.console.info(this._marshal('info', message, ...detail))
if (this.environment === 'development') {
this.console.info(message, ...detail)
} else {
this.console.info(this._marshal('info', message, ...detail))
}
}
warn(message?: any, ...detail: any[]) {
@ -89,7 +101,11 @@ export class ConsoleLogger implements Logger {
return
}
this.console.warn(this._marshal('warn', message, ...detail))
if (this.environment === 'development') {
this.console.warn(message, ...detail)
} else {
this.console.warn(this._marshal('warn', message, ...detail))
}
}
error(message?: any, ...detail: any[]) {
@ -97,7 +113,11 @@ export class ConsoleLogger implements Logger {
return
}
this.console.error(this._marshal('error', message, ...detail))
if (this.environment === 'development') {
this.console.error(message, ...detail)
} else {
this.console.error(this._marshal('error', message, ...detail))
}
}
protected _marshal(level: LogLevel, message?: any, ...detail: any[]): string {

Wyświetl plik

@ -16,7 +16,16 @@ export const app = new OpenAPIHono()
app.use(sentry())
app.use(compress())
app.use(cors())
app.use(
cors({
origin: '*',
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['POST', 'GET', 'OPTIONS'],
exposeHeaders: ['Content-Length'],
maxAge: 600,
credentials: true
})
)
app.use(middleware.init)
app.use(middleware.accessLogger)
app.use(middleware.responseTime)

Wyświetl plik

@ -1,7 +1,11 @@
import { config } from '@fisch0920/config/eslint'
import drizzle from 'eslint-plugin-drizzle'
export default [
...config,
{
ignores: ['**/out/**', 'packages/api-client/src/openapi.d.ts']
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
@ -10,6 +14,20 @@ export default [
}
},
{
ignores: ['**/out/**', 'packages/api-client/src/openapi.d.ts']
files: ['packages/cli/src/**/*.ts'],
rules: {
'no-console': 'off',
'no-process-env': 'off',
'unicorn/no-process-exit': 'off'
}
},
{
files: ['apps/api/src/**/*.ts'],
plugins: {
drizzle
},
rules: {
...drizzle.configs.recommended.rules
}
}
]

Wyświetl plik

@ -24,6 +24,7 @@
},
"dependencies": {
"@agentic/platform-core": "workspace:*",
"better-auth": "^1.2.8",
"ky": "catalog:",
"type-fest": "catalog:"
},

Wyświetl plik

@ -1,30 +1,55 @@
import type { Simplify } from 'type-fest'
import { getEnv, sanitizeSearchParams } from '@agentic/platform-core'
import { assert, getEnv, sanitizeSearchParams } from '@agentic/platform-core'
import { createAuthClient } from 'better-auth/client'
import { username } from 'better-auth/plugins'
import defaultKy, { type KyInstance } from 'ky'
import type { operations } from './openapi'
import type { AuthSession } from './types'
export class AgenticApiClient {
static readonly DEFAULT_API_BASE_URL = 'https://api.agentic.so'
public readonly ky: KyInstance
public readonly apiBaseUrl: string
public readonly authClient: ReturnType<typeof createAuthClient>
public ky: KyInstance
constructor({
apiKey = getEnv('AGENTIC_API_KEY'),
apiCookie = getEnv('AGENTIC_API_COOKIE'),
apiBaseUrl = AgenticApiClient.DEFAULT_API_BASE_URL,
ky = defaultKy
}: {
apiKey?: string
apiCookie?: string
apiBaseUrl?: string
ky?: KyInstance
}) {
assert(apiBaseUrl, 'AgenticApiClient missing required "apiBaseUrl"')
this.apiBaseUrl = apiBaseUrl
this.ky = ky.extend({
prefixUrl: apiBaseUrl,
headers: { Authorization: `Bearer ${apiKey}` }
// headers: { Authorization: `Bearer ${apiKey}` }
headers: { cookie: apiCookie }
})
this.authClient = createAuthClient({
baseURL: `${apiBaseUrl}/v1/auth`,
plugins: [username()]
})
}
async getAuthSession(cookie?: string): Promise<AuthSession> {
return this.ky
.get('v1/auth/get-session', cookie ? { headers: { cookie } } : {})
.json<AuthSession>()
}
async setAuthSession(cookie: string): Promise<AuthSession> {
this.ky = this.ky.extend({
headers: { cookie }
})
return this.getAuthSession()
}
async getUser({

Wyświetl plik

@ -26,3 +26,31 @@ export type PricingPlan = components['schemas']['PricingPlan']
export type PricingPlanName = components['schemas']['name']
export type PricingPlanSlug = components['schemas']['slug']
export type PricingPlanLabel = components['schemas']['label']
export type AuthSession = {
session: Session
user: AuthUser
}
export interface Session {
id: string
token: string
userId: string
ipAddress?: string | null
userAgent?: string | null
expiresAt: string
createdAt: string
updatedAt: string
}
export interface AuthUser {
id: string
name: string
role: string
username?: string
email: string
emailVerified: boolean
image?: string
createdAt: string
updatedAt: string
}

Wyświetl plik

@ -29,10 +29,14 @@
"dependencies": {
"@agentic/platform-api-client": "workspace:*",
"@agentic/platform-core": "workspace:*",
"better-auth": "^1.2.8",
"@hono/node-server": "^1.14.1",
"commander": "^14.0.0",
"conf": "^13.1.0",
"dotenv": "catalog:",
"get-port": "^7.1.0",
"hono": "^4.7.9",
"inquirer": "^9.2.15",
"open": "^10.1.2",
"ora": "^8.2.0",
"restore-cursor": "catalog:",
"zod": "catalog:"

Wyświetl plik

@ -0,0 +1,35 @@
import type {
AgenticApiClient,
AuthSession
} from '@agentic/platform-api-client'
import { assert } from '@agentic/platform-core'
import { AuthStore } from './store'
export async function authWithEmailPassword({
client,
email,
password
}: {
client: AgenticApiClient
email: string
password: string
}): Promise<AuthSession> {
let cookie: string | undefined
await client.authClient.signIn.email({
email,
password,
fetchOptions: {
onSuccess: ({ response }) => {
cookie = response.headers.get('set-cookie')!
}
}
})
assert(cookie, 'Failed to get auth cookie')
const session = await client.setAuthSession(cookie)
assert(session, 'Failed to get auth session')
AuthStore.setAuth({ cookie, session })
return session
}

Wyświetl plik

@ -0,0 +1,88 @@
import type {
AgenticApiClient,
AuthSession
} from '@agentic/platform-api-client'
import { assert } from '@agentic/platform-core'
import { serve } from '@hono/node-server'
import getPort from 'get-port'
import { Hono } from 'hono'
import open from 'open'
import { oraPromise } from 'ora'
import { client } from './client'
import { AuthStore } from './store'
export async function authWithGitHub({
preferredPort = 6013
}: {
client: AgenticApiClient
preferredPort?: number
}): Promise<AuthSession> {
const port = await getPort({ port: preferredPort })
const app = new Hono()
let _resolveAuth: any
let _rejectAuth: any
const authP = new Promise<AuthSession>((resolve, reject) => {
_resolveAuth = resolve
_rejectAuth = reject
})
app.get('/callback/github/success', async (c) => {
const cookie = c.req.header().cookie
assert(cookie, 'Missing required auth cookie header')
const session = await client.setAuthSession(cookie)
assert(session, 'Failed to get auth session')
AuthStore.setAuth({ cookie, session })
_resolveAuth(session)
return c.text(
'Huzzah! You are now signed in to the Agentic CLI with GitHub.\n\nYou may close this browser tab. 😄'
)
})
const server = serve({
fetch: app.fetch,
port
})
// TODO: clean these up
process.on('SIGINT', () => {
server.close()
process.exit(0)
})
process.on('SIGTERM', () => {
server.close((err) => {
if (err) {
console.error(err)
process.exit(1)
}
process.exit(0)
})
})
const res = await client.authClient.signIn.social({
provider: 'github',
callbackURL: `http://localhost:${port}/callback/github/success`
})
assert(
!res.error,
['Error signing in with GitHub', res.error?.code, res.error?.message]
.filter(Boolean)
.join(', ')
)
assert(res.data?.url, 'No URL returned from authClient.signIn.social')
await open(res.data.url)
const session = await oraPromise(authP, {
text: 'Authenticating with GitHub',
successText: 'You are now signed in with GitHub.',
failText: 'Failed to authenticate with GitHub.'
})
server.close()
return session
}

Wyświetl plik

@ -1,6 +1,9 @@
import { AgenticApiClient } from '@agentic/platform-api-client'
import { AuthStore } from './store'
// Create a singleton instance of the API client
export const client = new AgenticApiClient({
apiKey: process.env.AGENTIC_API_KEY
apiCookie: AuthStore.tryGetAuth()?.cookie,
apiBaseUrl: 'http://localhost:3000'
})

Wyświetl plik

@ -1,37 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { Command } from 'commander'
import ora from 'ora'
import { client } from '../client'
export const deploy = new Command('deploy')
.description('Creates a new deployment')
.argument('[path]', 'path to project directory', process.cwd())
.action(async (projectPath: string) => {
const spinner = ora('Creating deployment').start()
try {
// Read agentic.json from the project path
const configPath = path.join(projectPath, 'agentic.json')
const configContent = await fs.readFile(configPath, 'utf8')
const config = JSON.parse(configContent)
const deployment = await client.createDeployment(
{
identifier: config.name,
projectId: config.projectId,
version: config.version,
originUrl: config.originUrl || '',
pricingPlans: config.pricingPlans || []
},
{}
)
spinner.succeed('Deployment created successfully')
console.log(JSON.stringify(deployment, null, 2))
} catch (err) {
spinner.fail('Failed to create deployment')
console.error(err)
process.exit(1)
}
})

Wyświetl plik

@ -1,20 +0,0 @@
import { Command } from 'commander'
import ora from 'ora'
import { client } from '../client'
export const get = new Command('get')
.description('Gets details for a specific deployment')
.argument('<id>', 'deployment ID')
.action(async (id: string) => {
const spinner = ora('Fetching deployment details').start()
try {
const deployment = await client.getDeployment({ deploymentId: id })
spinner.succeed('Deployment details retrieved')
console.log(JSON.stringify(deployment, null, 2))
} catch (err) {
spinner.fail('Failed to fetch deployment details')
console.error(err)
process.exit(1)
}
})

Wyświetl plik

@ -1,21 +0,0 @@
import { Command } from 'commander'
import ora from 'ora'
import { client } from '../client'
export const ls = new Command('ls')
.alias('list')
.description('Lists deployments by project')
.argument('[project]', 'project ID')
.action(async (projectId?: string) => {
const spinner = ora('Fetching deployments').start()
try {
const deployments = await client.listDeployments({ projectId })
spinner.succeed('Deployments retrieved')
console.log(JSON.stringify(deployments, null, 2))
} catch (err) {
spinner.fail('Failed to fetch deployments')
console.error(err)
process.exit(1)
}
})

Wyświetl plik

@ -1,23 +1,21 @@
import { Command } from 'commander'
import ora from 'ora'
import { client } from '../client'
// import ora from 'ora'
export const publish = new Command('publish')
.description('Publishes a deployment')
.argument('[deploymentId]', 'deployment ID')
.action(async (deploymentId: string) => {
const spinner = ora('Publishing deployment').start()
try {
const deployment = await client.publishDeployment(
{ version: '1.0.0' },
{ deploymentId }
)
spinner.succeed('Deployment published successfully')
console.log(JSON.stringify(deployment, null, 2))
} catch (err) {
spinner.fail('Failed to publish deployment')
console.error(err)
process.exit(1)
}
.action(async (_opts) => {
// const spinner = ora('Publishing deployment').start()
// try {
// const deployment = await client.publishDeployment(
// { version: '1.0.0' },
// { deploymentId }
// )
// spinner.succeed('Deployment published successfully')
// console.log(JSON.stringify(deployment, null, 2))
// } catch (err) {
// spinner.fail('Failed to publish deployment')
// console.error(err)
// process.exit(1)
// }
})

Wyświetl plik

@ -2,8 +2,6 @@ import { Command } from 'commander'
import inquirer from 'inquirer'
import ora from 'ora'
import { client } from '../client'
export const rm = new Command('rm')
.description('Removes deployments')
.argument('[deploymentIds...]', 'deployment IDs to remove')

Wyświetl plik

@ -1,15 +1,40 @@
import { Command } from 'commander'
import type { AuthSession } from '@agentic/platform-api-client'
import { Command, InvalidArgumentError } from 'commander'
export const signin = new Command('login')
.alias('signin')
.description(
'Signs in to Agentic. If no credentials are provided, uses GitHub auth.'
)
.option('-u, --username <username>', 'account username')
.option('-e, --email <email>', 'account email')
.option('-p, --password <password>', 'account password')
.action(async (_opts) => {
import type { Context } from '../types'
import { authWithEmailPassword } from '../auth-with-email-password'
import { authWithGitHub } from '../auth-with-github'
export function registerSigninCommand({ client, program, logger }: Context) {
const command = new Command('login')
.alias('signin')
.description(
'Signs in to Agentic. If no credentials are provided, uses GitHub auth.'
)
// TODO
// eslint-disable-next-line no-console
console.log('TODO: signin')
})
// .option('-u, --username <username>', 'account username')
.option('-e, --email <email>', 'account email')
.option('-p, --password <password>', 'account password')
.action(async (opts) => {
let session: AuthSession | undefined
if (opts.email) {
if (!opts.password) {
throw new InvalidArgumentError(
'Password is required when using email'
)
}
session = await authWithEmailPassword({
client,
email: opts.email,
password: opts.password
})
} else {
session = await authWithGitHub({ client })
}
logger.log(session)
})
program.addCommand(command)
}

Wyświetl plik

@ -1,15 +1,20 @@
import { Command } from 'commander'
import { getAuth } from '../store'
import type { Context } from '../types'
import { AuthStore } from '../store'
export const whoami = new Command('whoami')
.description('Displays info about the current user')
.action(async () => {
const auth = getAuth()
export function registerWhoAmICommand({ client, program }: Context) {
const command = new Command('whoami')
.description('Displays info about the current user')
.action(async () => {
if (!AuthStore.isAuthenticated()) {
console.log('Not signed in')
return
}
// TODO
// eslint-disable-next-line no-console
console.log(
JSON.stringify({ user: auth.user, team: auth.teamSlug }, null, 2)
)
})
const res = await client.getAuthSession()
console.log(res)
})
program.addCommand(command)
}

Wyświetl plik

@ -1,34 +1,46 @@
import { createAuthClient } from 'better-auth/client'
import 'dotenv/config'
import { AgenticApiClient } from '@agentic/platform-api-client'
import { Command } from 'commander'
import restoreCursor from 'restore-cursor'
import { deploy } from './commands/deploy'
import { get } from './commands/get'
import { ls } from './commands/ls'
import { publish } from './commands/publish'
import { rm } from './commands/rm'
import { signin } from './commands/signin'
const authClient = createAuthClient({
baseURL: 'http://localhost:3000/v1/auth'
})
import { registerSigninCommand } from './commands/signin'
import { registerWhoAmICommand } from './commands/whoami'
import { AuthStore } from './store'
async function main() {
restoreCursor()
const res = await authClient.signIn.social({
provider: 'github'
const client = new AgenticApiClient({
apiCookie: AuthStore.tryGetAuth()?.cookie,
apiBaseUrl: process.env.AGENTIC_API_BASE_URL
})
console.log(res)
return
const program = new Command()
program.addCommand(signin)
program.addCommand(get)
program.addCommand(ls)
program.addCommand(publish)
program.addCommand(rm)
program.addCommand(deploy)
const program = new Command('agentic')
.option('-j, --json', 'Print output in JSON format')
.showHelpAfterError()
const logger = {
log: (...args: any[]) => {
if (program.opts().json) {
console.log(
args.length === 1 ? JSON.stringify(args[0]) : JSON.stringify(args)
)
} else {
console.log(...args)
}
}
}
const ctx = {
client,
program,
logger
}
// Register all commands
registerSigninCommand(ctx)
registerWhoAmICommand(ctx)
program.parse()
}

Wyświetl plik

@ -1,63 +1,70 @@
import type { User } from '@agentic/platform-api-client'
import type { AuthSession } from '@agentic/platform-api-client'
import { assert } from '@agentic/platform-core'
import Conf from 'conf'
export const store = new Conf({ projectName: 'agentic' })
export type Auth = {
token: string
user: User
export type AuthState = {
cookie: string
session: AuthSession
teamId?: string
teamSlug?: string
}
const keyTeamId = 'teamId'
const keyTeamSlug = 'teamSlug'
const keyToken = 'token'
const keyUser = 'user'
const keyCookie = 'cookie'
const keySession = 'session'
export function isAuthenticated() {
return store.has(keyToken) && store.has(keyUser)
}
export const AuthStore = {
store: new Conf({ projectName: 'agentic' }),
export function requireAuth() {
assert(
isAuthenticated(),
'Command requires authentication. Please login first.'
)
}
isAuthenticated() {
return this.store.has(keyCookie) && this.store.has(keySession)
},
export function getAuth(): Auth {
requireAuth()
requireAuth() {
assert(
this.isAuthenticated(),
'Command requires authentication. Please login first.'
)
},
return {
token: store.get(keyToken),
user: store.get(keyUser),
teamId: store.get(keyTeamId),
teamSlug: store.get(keyTeamSlug)
} as Auth
}
tryGetAuth(): AuthState | undefined {
if (!this.isAuthenticated()) {
return undefined
}
export function signinUser({ token, user }: { token: string; user: string }) {
store.set(keyToken, token)
store.set(keyUser, user)
store.delete(keyTeamId)
store.delete(keyTeamSlug)
}
return {
cookie: this.store.get(keyCookie),
session: this.store.get(keySession),
teamId: this.store.get(keyTeamId),
teamSlug: this.store.get(keyTeamSlug)
} as AuthState
},
export function signout() {
store.delete(keyToken)
store.delete(keyUser)
store.delete(keyTeamId)
store.delete(keyTeamSlug)
}
getAuth(): AuthState {
this.requireAuth()
return this.tryGetAuth()!
},
export function switchTeam(team?: { id: string; slug: string }) {
if (team?.id) {
store.set(keyTeamId, team.id)
store.set(keyTeamSlug, team.slug)
} else {
store.delete(keyTeamId)
store.delete(keyTeamSlug)
setAuth({ cookie, session }: { cookie: string; session: AuthSession }) {
this.store.set(keyCookie, cookie)
this.store.set(keySession, session)
},
clearAuth() {
this.store.delete(keyCookie)
this.store.delete(keySession)
this.store.delete(keyTeamId)
this.store.delete(keyTeamSlug)
},
switchTeam(team?: { id: string; slug: string }) {
if (team?.id) {
this.store.set(keyTeamId, team.id)
this.store.set(keyTeamSlug, team.slug)
} else {
this.store.delete(keyTeamId)
this.store.delete(keyTeamSlug)
}
}
}

Wyświetl plik

@ -0,0 +1,10 @@
import type { AgenticApiClient } from '@agentic/platform-api-client'
import type { Command } from 'commander'
export type Context = {
client: AgenticApiClient
program: Command
logger: {
log: (...args: any[]) => void
}
}

Wyświetl plik

@ -219,6 +219,9 @@ importers:
'@agentic/platform-core':
specifier: workspace:*
version: link:../core
better-auth:
specifier: ^1.2.8
version: 1.2.8
ky:
specifier: 'catalog:'
version: 1.8.1
@ -238,18 +241,30 @@ importers:
'@agentic/platform-core':
specifier: workspace:*
version: link:../core
better-auth:
specifier: ^1.2.8
version: 1.2.8
'@hono/node-server':
specifier: ^1.14.1
version: 1.14.1(hono@4.7.9)
commander:
specifier: ^14.0.0
version: 14.0.0
conf:
specifier: ^13.1.0
version: 13.1.0
dotenv:
specifier: 'catalog:'
version: 16.5.0
get-port:
specifier: ^7.1.0
version: 7.1.0
hono:
specifier: ^4.7.9
version: 4.7.9
inquirer:
specifier: ^9.2.15
version: 9.3.7
open:
specifier: ^10.1.2
version: 10.1.2
ora:
specifier: ^8.2.0
version: 8.2.0
@ -1558,6 +1573,10 @@ packages:
resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==}
engines: {node: '>=18.20'}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -1771,6 +1790,14 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
default-browser-id@5.0.0:
resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==}
engines: {node: '>=18'}
default-browser@5.2.1:
resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==}
engines: {node: '>=18'}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
@ -1778,6 +1805,10 @@ packages:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
@ -2285,6 +2316,10 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-port@7.1.0:
resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==}
engines: {node: '>=16'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
@ -2474,6 +2509,11 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -2502,6 +2542,11 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
@ -2593,6 +2638,10 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-wsl@3.1.0:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@ -2943,6 +2992,10 @@ packages:
resolution: {integrity: sha512-M7CJbmv7UCopc0neRKdzfoGWaVZC+xC1925GitKH9EAqYFzX9//25Q7oX4+jw0tiCCj+t5l6VZh8UPH23NZkMA==}
hasBin: true
open@10.1.2:
resolution: {integrity: sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==}
engines: {node: '>=18'}
openapi-typescript@7.8.0:
resolution: {integrity: sha512-1EeVWmDzi16A+siQlo/SwSGIT7HwaFAVjvMA7/jG5HMLSnrUOzPL7uSTRZZa4v/LCRxHTApHKtNY6glApEoiUQ==}
hasBin: true
@ -3265,6 +3318,10 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
run-applescript@7.0.0:
resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==}
engines: {node: '>=18'}
run-async@3.0.0:
resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==}
engines: {node: '>=0.12.0'}
@ -5177,6 +5234,10 @@ snapshots:
builtin-modules@5.0.0: {}
bundle-name@4.1.0:
dependencies:
run-applescript: 7.0.0
bundle-require@5.1.0(esbuild@0.25.4):
dependencies:
esbuild: 0.25.4
@ -5360,6 +5421,13 @@ snapshots:
deep-is@0.1.4: {}
default-browser-id@5.0.0: {}
default-browser@5.2.1:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.0
defaults@1.0.4:
dependencies:
clone: 1.0.4
@ -5370,6 +5438,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
define-lazy-prop@3.0.0: {}
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
@ -5998,6 +6068,8 @@ snapshots:
hasown: 2.0.2
math-intrinsics: 1.1.0
get-port@7.1.0: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
@ -6207,6 +6279,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-docker@3.0.0: {}
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@ -6232,6 +6306,10 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-interactive@1.0.0: {}
is-interactive@2.0.0: {}
@ -6304,6 +6382,10 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-wsl@3.1.0:
dependencies:
is-inside-container: 1.0.0
isarray@2.0.5: {}
isexe@2.0.0: {}
@ -6643,6 +6725,13 @@ snapshots:
dependencies:
which-pm-runs: 1.1.0
open@10.1.2:
dependencies:
default-browser: 5.2.1
define-lazy-prop: 3.0.0
is-inside-container: 1.0.0
is-wsl: 3.1.0
openapi-typescript@7.8.0(typescript@5.8.3):
dependencies:
'@redocly/openapi-core': 1.34.3(supports-color@10.0.0)
@ -6978,6 +7067,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
run-applescript@7.0.0: {}
run-async@3.0.0: {}
run-parallel@1.2.0: