Skip to content

Commit 42ded94

Browse files
committed
ci: add release workflow and artifact uploads
- Update CI to build both Chrome and Firefox extensions - Upload .zip and .xpi artifacts on every build (30-day retention) - Add release workflow triggered on version tags (v*) - Update .gitignore to ignore all release packages in repo root
1 parent 80fe853 commit 42ded94

37 files changed

+2156
-245
lines changed

.github/workflows/ci.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,23 @@ jobs:
6464
cache: 'npm'
6565
- run: npm ci
6666
- run: npm run build
67+
- run: npm run build:firefox
68+
- name: Package Chrome Extension
69+
run: |
70+
cd dist && zip -r ../parchi-chrome-${{ github.run_number }}.zip . && cd ..
71+
- name: Package Firefox Extension
72+
run: |
73+
cd dist-firefox && zip -r ../parchi-firefox-${{ github.run_number }}.xpi . && cd ..
74+
- uses: actions/upload-artifact@v4
75+
with:
76+
name: extension-chrome
77+
path: parchi-chrome-${{ github.run_number }}.zip
78+
retention-days: 30
6779
- uses: actions/upload-artifact@v4
6880
with:
69-
name: extension-dist
70-
path: dist/
71-
retention-days: 7
81+
name: extension-firefox
82+
path: parchi-firefox-${{ github.run_number }}.xpi
83+
retention-days: 30
7284

7385
test:
7486
name: Unit Tests

.github/workflows/release.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build-and-release:
13+
name: Build and Release
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: '20'
20+
cache: 'npm'
21+
- run: npm ci
22+
23+
- name: Get version from package.json
24+
id: get_version
25+
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
26+
27+
- run: npm run build
28+
- run: npm run build:firefox
29+
30+
- name: Package Chrome Extension
31+
run: |
32+
cd dist && zip -r ../parchi-chrome-${{ steps.get_version.outputs.VERSION }}.zip . && cd ..
33+
- name: Package Firefox Extension
34+
run: |
35+
cd dist-firefox && zip -r ../parchi-firefox-${{ steps.get_version.outputs.VERSION }}.xpi . && cd ..
36+
37+
- name: Create Release
38+
uses: softprops/action-gh-release@v1
39+
with:
40+
files: |
41+
parchi-chrome-*.zip
42+
parchi-firefox-*.xpi
43+
generate_release_notes: true
44+
env:
45+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ logs/
2929
*.zip
3030
*.crx
3131
*.pem
32+
*.xpi
33+
34+
# Release packages in repo root
35+
/parchi-*.zip
36+
/parchi-*.xpi
37+
/dist.crx
3238

3339
# Environment files
3440
.env

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parchi-extension",
3-
"version": "0.4.2",
3+
"version": "0.4.3",
44
"description": "Warm-paper browser automation extension with configurable LLM providers",
55
"type": "module",
66
"workspaces": [

packages/backend/convex/aiProxy.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const jsonResponse = (status: number, body: Record<string, unknown>) =>
1818
});
1919

2020
type ProviderTarget = {
21-
provider: 'openai' | 'anthropic' | 'kimi';
21+
provider: 'openai' | 'anthropic' | 'kimi' | 'openrouter';
2222
upstreamBaseUrl: string;
2323
defaultPath: string;
2424
upstreamApiKey: string;
@@ -42,6 +42,14 @@ const resolveProviderTarget = (request: Request): ProviderTarget | null => {
4242
upstreamApiKey: String(process.env.KIMI_API_KEY || '').trim(),
4343
};
4444
}
45+
if (requestPath.startsWith('/ai-proxy/openrouter')) {
46+
return {
47+
provider: 'openrouter',
48+
upstreamBaseUrl: 'https://openrouter.ai/api',
49+
defaultPath: '/v1/chat/completions',
50+
upstreamApiKey: String(process.env.OPENROUTER_API_KEY || '').trim(),
51+
};
52+
}
4553
if (requestPath.startsWith('/ai-proxy/openai') || requestPath === '/ai-proxy') {
4654
return {
4755
provider: 'openai',
@@ -53,10 +61,16 @@ const resolveProviderTarget = (request: Request): ProviderTarget | null => {
5361
return null;
5462
};
5563

56-
const resolveForwardPath = (request: Request, provider: 'openai' | 'anthropic' | 'kimi', defaultPath: string) => {
64+
const providerPrefixMap: Record<string, string> = {
65+
anthropic: '/ai-proxy/anthropic',
66+
kimi: '/ai-proxy/kimi',
67+
openrouter: '/ai-proxy/openrouter',
68+
openai: '/ai-proxy/openai',
69+
};
70+
71+
const resolveForwardPath = (request: Request, provider: string, defaultPath: string) => {
5772
const requestPath = new URL(request.url).pathname;
58-
const prefix =
59-
provider === 'anthropic' ? '/ai-proxy/anthropic' : provider === 'kimi' ? '/ai-proxy/kimi' : '/ai-proxy/openai';
73+
const prefix = providerPrefixMap[provider] || '/ai-proxy/openai';
6074
const suffix = requestPath.startsWith(prefix) ? requestPath.slice(prefix.length) : '';
6175
return suffix || defaultPath;
6276
};
@@ -80,9 +94,10 @@ export const aiProxy = httpActionGeneric(async (ctx, request) => {
8094
}
8195

8296
const subscription = await ctx.runQuery(anyApi.subscriptions.getByUserId, { userId });
83-
const isPaid = Boolean(subscription && subscription.plan === 'pro' && subscription.status === 'active');
84-
if (!isPaid) {
85-
return jsonResponse(402, { error: 'Active paid subscription required' });
97+
const creditBalance = subscription?.creditBalanceCents ?? 0;
98+
const hasLegacySub = Boolean(subscription && subscription.plan === 'pro' && subscription.status === 'active');
99+
if (creditBalance <= 0 && !hasLegacySub) {
100+
return jsonResponse(402, { error: 'Insufficient credits. Purchase credits to continue.' });
86101
}
87102

88103
let payload: Record<string, any>;
@@ -92,10 +107,22 @@ export const aiProxy = httpActionGeneric(async (ctx, request) => {
92107
return jsonResponse(400, { error: 'Invalid JSON payload' });
93108
}
94109

110+
// Estimate cost: ~$0.03/1K tokens → 0.003 cents/token, minimum 1 cent
111+
const maxTokens = Number(payload?.max_tokens || payload?.maxTokens || 4096);
112+
const estimatedCostCents = Math.max(1, Math.ceil(maxTokens * 0.003));
113+
114+
// Deduct credits upfront (best-effort for streaming responses)
115+
if (creditBalance > 0) {
116+
await ctx.runMutation(anyApi.subscriptions.deductCredits, {
117+
userId,
118+
amountCents: Math.min(estimatedCostCents, creditBalance),
119+
});
120+
}
121+
95122
await ctx.runMutation(anyApi.subscriptions.recordUsage, {
96123
userId,
97124
requestCountIncrement: 1,
98-
tokenEstimate: Number(payload?.max_tokens || payload?.maxTokens || 0),
125+
tokenEstimate: maxTokens,
99126
});
100127

101128
const forwardPath = resolveForwardPath(request, providerTarget.provider, providerTarget.defaultPath);
@@ -106,6 +133,10 @@ export const aiProxy = httpActionGeneric(async (ctx, request) => {
106133

107134
if (providerTarget.provider === 'openai') {
108135
upstreamHeaders.set('authorization', `Bearer ${providerTarget.upstreamApiKey}`);
136+
} else if (providerTarget.provider === 'openrouter') {
137+
upstreamHeaders.set('authorization', `Bearer ${providerTarget.upstreamApiKey}`);
138+
upstreamHeaders.set('http-referer', 'https://parchi.app');
139+
upstreamHeaders.set('x-title', 'Parchi');
109140
} else if (providerTarget.provider === 'anthropic') {
110141
upstreamHeaders.set('x-api-key', providerTarget.upstreamApiKey);
111142
upstreamHeaders.set('anthropic-version', request.headers.get('anthropic-version') || '2023-06-01');

packages/backend/convex/http.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ http.route({
5555
handler: aiProxy,
5656
});
5757

58+
http.route({
59+
pathPrefix: '/ai-proxy/openrouter/',
60+
method: 'POST',
61+
handler: aiProxy,
62+
});
63+
64+
http.route({
65+
pathPrefix: '/ai-proxy/openrouter/',
66+
method: 'OPTIONS',
67+
handler: aiProxy,
68+
});
69+
5870
http.route({
5971
path: '/stripe-webhook',
6072
method: 'POST',

packages/backend/convex/payments.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,76 @@ export const createCheckoutSession = actionGeneric(async (ctx) => {
8080
};
8181
});
8282

83+
const CREDIT_PACKAGES_CENTS = [500, 1500, 5000] as const;
84+
85+
export const createCreditCheckoutSession = actionGeneric(async (ctx, args: { packageCents: number }) => {
86+
const userId = await getAuthUserId(ctx);
87+
if (!userId) throw new Error('Unauthorized');
88+
89+
const packageCents = Number(args.packageCents);
90+
if (!CREDIT_PACKAGES_CENTS.includes(packageCents as any)) {
91+
throw new Error(`Invalid credit package. Choose from: ${CREDIT_PACKAGES_CENTS.map((c) => `$${c / 100}`).join(', ')}`);
92+
}
93+
94+
const stripe = getStripeClient();
95+
const user = await ctx.runQuery(anyApi.users.me, {});
96+
const existing = await ctx.runQuery(anyApi.subscriptions.getByUserId, { userId });
97+
98+
let customerId = existing?.stripeCustomerId;
99+
if (!customerId) {
100+
const customer = await stripe.customers.create({
101+
email: user?.email || undefined,
102+
metadata: { convexUserId: String(userId) },
103+
});
104+
customerId = customer.id;
105+
}
106+
107+
const siteUrl = baseSiteUrl();
108+
const session = await stripe.checkout.sessions.create({
109+
mode: 'payment',
110+
customer: customerId,
111+
line_items: [
112+
{
113+
price_data: {
114+
currency: 'usd',
115+
unit_amount: packageCents,
116+
product_data: {
117+
name: `Parchi Credits — $${(packageCents / 100).toFixed(0)}`,
118+
},
119+
},
120+
quantity: 1,
121+
},
122+
],
123+
success_url: `${siteUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
124+
cancel_url: `${siteUrl}/billing/cancel`,
125+
metadata: {
126+
userId: String(userId),
127+
creditAmountCents: String(packageCents),
128+
},
129+
});
130+
131+
// Ensure a subscription record exists so the webhook can find it
132+
if (!existing) {
133+
await ctx.runMutation(anyApi.subscriptions.upsertForUser, {
134+
userId,
135+
plan: 'free',
136+
status: 'inactive',
137+
stripeCustomerId: customerId,
138+
});
139+
} else if (!existing.stripeCustomerId) {
140+
await ctx.runMutation(anyApi.subscriptions.upsertForUser, {
141+
userId,
142+
plan: existing.plan || 'free',
143+
status: existing.status || 'inactive',
144+
stripeCustomerId: customerId,
145+
stripeSubscriptionId: existing.stripeSubscriptionId,
146+
currentPeriodEnd: existing.currentPeriodEnd,
147+
});
148+
}
149+
150+
return { id: session.id, url: session.url };
151+
});
152+
83153
export const manageSubscription = actionGeneric(async (ctx) => {
84154
const userId = await getAuthUserId(ctx);
85155
if (!userId) throw new Error('Unauthorized');
@@ -124,7 +194,16 @@ export const stripeWebhook = httpActionGeneric(async (ctx, request) => {
124194
if (event.type === 'checkout.session.completed') {
125195
const session = event.data.object as Stripe.Checkout.Session;
126196
const metadataUserId = session.metadata?.userId;
127-
if (metadataUserId) {
197+
const creditAmountCents = Number(session.metadata?.creditAmountCents || 0);
198+
199+
if (metadataUserId && creditAmountCents > 0) {
200+
// One-time credit purchase — add credits to balance
201+
await ctx.runMutation(anyApi.subscriptions.addCredits, {
202+
userId: metadataUserId as any,
203+
amountCents: creditAmountCents,
204+
});
205+
} else if (metadataUserId) {
206+
// Subscription checkout — existing flow
128207
const subscriptionId =
129208
typeof session.subscription === 'string'
130209
? session.subscription

packages/backend/convex/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default defineSchema({
1212
stripeSubscriptionId: v.optional(v.string()),
1313
status: v.union(v.literal('active'), v.literal('canceled'), v.literal('past_due'), v.literal('inactive')),
1414
currentPeriodEnd: v.optional(v.number()),
15+
creditBalanceCents: v.optional(v.number()),
1516
})
1617
.index('by_userId', ['userId'])
1718
.index('by_stripeCustomerId', ['stripeCustomerId'])

packages/backend/convex/subscriptions.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const getCurrent = queryGeneric({
3030
plan: 'free',
3131
status: 'inactive',
3232
}),
33+
creditBalanceCents: subscription?.creditBalanceCents ?? 0,
3334
usage: usage || {
3435
requestCount: 0,
3536
tokensUsed: 0,
@@ -170,3 +171,69 @@ export const recordUsage = mutationGeneric({
170171
return ctx.db.get(createdId);
171172
},
172173
});
174+
175+
export const addCredits = mutationGeneric({
176+
args: {
177+
userId: v.id('users'),
178+
amountCents: v.number(),
179+
},
180+
handler: async (ctx, args) => {
181+
const existing = await ctx.db
182+
.query('subscriptions')
183+
.withIndex('by_userId', (q) => q.eq('userId', args.userId))
184+
.first();
185+
186+
if (existing?._id) {
187+
const newBalance = (existing.creditBalanceCents ?? 0) + args.amountCents;
188+
await ctx.db.patch(existing._id, { creditBalanceCents: newBalance });
189+
return { creditBalanceCents: newBalance };
190+
}
191+
192+
await ctx.db.insert('subscriptions', {
193+
userId: args.userId,
194+
plan: 'free',
195+
status: 'inactive',
196+
creditBalanceCents: args.amountCents,
197+
});
198+
return { creditBalanceCents: args.amountCents };
199+
},
200+
});
201+
202+
export const deductCredits = mutationGeneric({
203+
args: {
204+
userId: v.id('users'),
205+
amountCents: v.number(),
206+
},
207+
handler: async (ctx, args) => {
208+
const existing = await ctx.db
209+
.query('subscriptions')
210+
.withIndex('by_userId', (q) => q.eq('userId', args.userId))
211+
.first();
212+
213+
const currentBalance = existing?.creditBalanceCents ?? 0;
214+
if (currentBalance < args.amountCents) {
215+
return { success: false, remainingCents: currentBalance };
216+
}
217+
218+
const newBalance = currentBalance - args.amountCents;
219+
if (existing?._id) {
220+
await ctx.db.patch(existing._id, { creditBalanceCents: newBalance });
221+
}
222+
return { success: true, remainingCents: newBalance };
223+
},
224+
});
225+
226+
export const getBalance = queryGeneric({
227+
args: {},
228+
handler: async (ctx) => {
229+
const userId = await getAuthUserId(ctx);
230+
if (!userId) return { creditBalanceCents: 0 };
231+
232+
const subscription = await ctx.db
233+
.query('subscriptions')
234+
.withIndex('by_userId', (q) => q.eq('userId', userId))
235+
.first();
236+
237+
return { creditBalanceCents: subscription?.creditBalanceCents ?? 0 };
238+
},
239+
});

0 commit comments

Comments
 (0)