@galda/cli 0.10.27 → 0.10.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/lib.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { resolve, join, basename } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
- import { createPublicKey, verify as verifyRsaSignature } from 'node:crypto';
6
+ import { createPublicKey, verify as verifyRsaSignature, createHash } from 'node:crypto';
7
7
 
8
8
  // Worker agents Galda can drive. Galda is NOT tied to Claude Code — it runs on
9
9
  // whichever agent CLI you have. Neither is auto-installed and codex is never even
@@ -2775,8 +2775,11 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2775
2775
 
2776
2776
  // ---- billing entitlement gate (public self-serve launch, docs/BILLING-LAUNCH-PLAN.md) --
2777
2777
  // Mirrors workers/billing-api/src/entitlement.mjs's FREE_TIER_LIMITS/
2778
- // checkFreeTierLimit/resolveEntitlement exactly (Masa decision 2026-07-12:
2779
- // 5 pending goals, 19 lifetime goals, 1 project, $16/mo unlocks all three).
2778
+ // checkFreeTierLimit/resolveEntitlement exactly (Masa: 5 queued to-dos,
2779
+ // 20 total tasks, 1 project matching galda.app's own words. Updated
2780
+ // 2026-07-17: lifetime cap 19→20 so the wall says the same number the sales
2781
+ // page promises ("20 total tasks"); it used to block the 20th create, one short
2782
+ // of the promise. Pro unlocks all three; price is Stripe-derived, not in code.)
2780
2783
  // Deliberately duplicated rather than imported: engine/ and workers/billing-api/
2781
2784
  // are independently deployed (a user's Mac vs Cloudflare) and independently
2782
2785
  // tested — sharing a module across that boundary would couple two things
@@ -2784,10 +2787,21 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2784
2787
  // hand; both have their own test coverage against the same free-tier numbers.
2785
2788
  export const FREE_TIER_LIMITS = Object.freeze({
2786
2789
  maxPendingGoals: 5,
2787
- maxCumulativeGoals: 19,
2790
+ maxCumulativeGoals: 20,
2788
2791
  maxProjects: 1,
2789
2792
  });
2790
2793
 
2794
+ // The statuses that occupy a "queued to-do" slot (the 5-slot wall). Narrower
2795
+ // than "every open goal" on purpose: a goal in `review` has finished its work
2796
+ // and is waiting on a human — that's not a to-do in flight, and galda.app sells
2797
+ // the slot as "queued to-dos", so review must not eat one (Masa 2026-07-17,
2798
+ // "5枠は実行待ちだけ"). Kept as its own named list rather than filtering an
2799
+ // "all open" constant so the intent is legible where it's read.
2800
+ export const QUEUED_GOAL_STATUSES = Object.freeze([
2801
+ 'stacked', 'sending', 'pending', 'planning', 'needsInput',
2802
+ 'running', 'partial', 'failed', 'interrupted', 'blocked', 'retesting',
2803
+ ]);
2804
+
2791
2805
  export function checkFreeTierLimit(usage) {
2792
2806
  const pendingCount = Number(usage?.pendingCount) || 0;
2793
2807
  const cumulativeCount = Number(usage?.cumulativeCount) || 0;
@@ -3320,3 +3334,28 @@ export function classifyRelayClientConflict({ existingLock, alive, newIdentity }
3320
3334
  export function shouldEscalateConnectGateHint(waitedMs, email, thresholdMs = 15000) {
3321
3335
  return Number(waitedMs) >= thresholdMs && Boolean(String(email ?? '').trim());
3322
3336
  }
3337
+
3338
+ // --- device-flow (PKCE) sign-in helpers --------------------------------------
3339
+ // The browser no longer delivers the minted token to a live loopback port —
3340
+ // the engine POLLS the Worker to claim it (device-flow). PKCE ties the claim to
3341
+ // the engine that started the flow: the engine keeps a secret `verifier`, sends
3342
+ // only its SHA-256 `challenge` through the browser, and the Worker hands the
3343
+ // token back only to a claimant that can present the matching verifier. These
3344
+ // are pure so the crypto + response-shape parsing are unit-tested with no server.
3345
+ export function makeSigninChallenge(verifier) {
3346
+ return createHash('sha256').update(String(verifier ?? '')).digest('base64url');
3347
+ }
3348
+ export function verifierMatchesChallenge(verifier, challenge) {
3349
+ if (!verifier || !challenge) return false;
3350
+ return makeSigninChallenge(verifier) === String(challenge);
3351
+ }
3352
+ // Normalize the Worker's /connect/claim JSON into a single shape the poller can
3353
+ // switch on. Worker returns {pending:true} before the browser lands, {error} on
3354
+ // failure, {token,email} once minted. Anything malformed reads as an error so a
3355
+ // bad response never masquerades as "still pending" forever.
3356
+ export function parseClaimResponse(obj) {
3357
+ if (!obj || typeof obj !== 'object') return { state: 'error', error: 'malformed claim response' };
3358
+ if (obj.error) return { state: 'error', error: String(obj.error) };
3359
+ if (obj.token) return { state: 'ready', token: String(obj.token), email: obj.email != null ? String(obj.email) : undefined };
3360
+ return { state: 'pending' };
3361
+ }
package/engine/server.mjs CHANGED
@@ -20,8 +20,8 @@ import { resolve, dirname, join, basename } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
- import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
23
+ import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -177,6 +177,19 @@ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
177
177
  // it survives restarts.
178
178
  const licenseFile = join(DATA_DIR, 'license.token');
179
179
  let licenseState = { email: null, isPaying: false, verifiedAt: 0 }; // in-memory cache of the last successful verify (isPaying is the token's snapshot; the live gate re-checks)
180
+ // Test-only seam: override the license-verify public key via env so tests can
181
+ // sign tokens the running server actually accepts (the baked LICENSE_PUBLIC_JWK
182
+ // has no committed private half, so the licensed-but-lapsed path is otherwise
183
+ // untestable — see docs/HANDOFF-A2C-WORKERS-DEV-CLEANUP.md item 6). Unset in
184
+ // prod → verifyLicenseToken falls back to the baked key. This is NOT a payment
185
+ // bypass: forging an identity here only picks WHO the email is; whether they're
186
+ // paying is still the live remote billing-api (fetchIsPaying). An env escape
187
+ // hatch already exists on the gate (MANAGER_ENTITLEMENT_BYPASS=1) — same spirit.
188
+ const LICENSE_PUBKEY_OVERRIDE = (() => {
189
+ const raw = process.env.MANAGER_LICENSE_PUBKEY;
190
+ if (!raw) return undefined;
191
+ try { return JSON.parse(raw); } catch { return undefined; }
192
+ })();
180
193
 
181
194
  // Analytics identity (A+): once signed in, scope usage events to the ACCOUNT
182
195
  // (the billing Worker hashes this email → the same account_id the accounts table
@@ -191,26 +204,72 @@ let _freeExhaustedEmitted = false;
191
204
  // the nonce, round-trips it through the flow, and requires it back — so a random
192
205
  // page can't drive the local /oauth/callback with a token the user never asked for.
193
206
  const SIGNIN_NONCE_TTL_MS = 10 * 60 * 1000;
194
- const pendingSignins = new Map(); // nonce -> expiresAt
207
+ const pendingSignins = new Map(); // nonce -> { expiresAt, verifier }
195
208
  function newSigninNonce() {
196
209
  const now = Date.now();
197
- for (const [k, exp] of pendingSignins) if (exp < now) pendingSignins.delete(k); // opportunistic sweep
210
+ for (const [k, v] of pendingSignins) if (v.expiresAt < now) pendingSignins.delete(k); // opportunistic sweep
198
211
  const nonce = randomBytes(18).toString('base64url');
199
- pendingSignins.set(nonce, now + SIGNIN_NONCE_TTL_MS);
200
- return nonce;
212
+ const verifier = randomBytes(32).toString('base64url'); // PKCE secret — never leaves the engine
213
+ pendingSignins.set(nonce, { expiresAt: now + SIGNIN_NONCE_TTL_MS, verifier });
214
+ return { nonce, verifier };
201
215
  }
202
216
  function consumeSigninNonce(nonce) {
203
- const exp = pendingSignins.get(nonce);
204
- if (exp === undefined) return false;
217
+ const entry = pendingSignins.get(nonce);
218
+ if (entry === undefined) return false;
205
219
  pendingSignins.delete(nonce);
206
- return exp >= Date.now();
220
+ return entry.expiresAt >= Date.now();
221
+ }
222
+
223
+ // Device-flow (PKCE) sign-in. The old loopback flow delivered the minted token
224
+ // to http://127.0.0.1:<port>/oauth/callback — which dead-ends with
225
+ // ERR_CONNECTION_REFUSED the moment that ephemeral port is gone (engine
226
+ // restarted, MANAGER_PORT=0 instance exited). Instead the engine POLLS the
227
+ // Worker to claim the token and the browser lands on a HOSTED success page, so
228
+ // delivery never depends on the browser reaching a live local port.
229
+ const activeSigninPolls = new Set(); // nonces with a live poller (one per nonce)
230
+ function buildSigninUrl() {
231
+ const { nonce, verifier } = newSigninNonce();
232
+ const challenge = makeSigninChallenge(verifier);
233
+ startSigninPoll(nonce, verifier);
234
+ return `${BILLING_API_URL}/connect?state=${encodeURIComponent(nonce)}&challenge=${encodeURIComponent(challenge)}`;
235
+ }
236
+ // Best-effort background loop: GET /connect/claim every 2s until the token is
237
+ // minted (ready), an error is stored, or the nonce's 10-min TTL lapses. On
238
+ // success it activates the license and nudges the app over SSE (the same
239
+ // 'signed-in'/'signin-error' events the popup postMessage used to carry). Never
240
+ // throws — an unreachable Worker just means we try again next tick.
241
+ async function startSigninPoll(nonce, verifier) {
242
+ if (!BILLING_API_URL || activeSigninPolls.has(nonce)) return;
243
+ activeSigninPolls.add(nonce);
244
+ const deadline = Date.now() + SIGNIN_NONCE_TTL_MS;
245
+ const stop = () => { activeSigninPolls.delete(nonce); pendingSignins.delete(nonce); };
246
+ try {
247
+ while (Date.now() < deadline) {
248
+ await new Promise((r) => { const t = setTimeout(r, 2000); t.unref?.(); });
249
+ if (!activeSigninPolls.has(nonce)) return; // stopped elsewhere
250
+ try {
251
+ const url = `${BILLING_API_URL}/connect/claim?state=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(verifier)}`;
252
+ const res = await fetch(url);
253
+ if (!res.ok) continue; // 403 mismatch / transient — keep trying until TTL
254
+ const parsed = parseClaimResponse(await res.json().catch(() => null));
255
+ if (parsed.state === 'ready') {
256
+ const result = await activateLicense(parsed.token);
257
+ if (result.ok) send({ ev: 'signed-in', email: result.email });
258
+ else send({ ev: 'signin-error', error: result.error });
259
+ return;
260
+ }
261
+ if (parsed.state === 'error') { send({ ev: 'signin-error', error: parsed.error }); return; }
262
+ // pending → loop again
263
+ } catch { /* best-effort — retry next tick */ }
264
+ }
265
+ } finally { stop(); }
207
266
  }
208
267
 
209
268
  async function loadLicenseFromDisk() {
210
269
  if (!existsSync(licenseFile)) return;
211
270
  const token = readFileSync(licenseFile, 'utf8').trim();
212
271
  if (!token) return;
213
- const result = await verifyLicenseToken({ token });
272
+ const result = await verifyLicenseToken({ token, publicJwk: LICENSE_PUBKEY_OVERRIDE });
214
273
  if (result.valid) licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
215
274
  }
216
275
  loadLicenseFromDisk();
@@ -219,7 +278,7 @@ loadLicenseFromDisk();
219
278
  // token to disk that doesn't actually verify, so a bad paste can't silently
220
279
  // wedge the app into "licensed but broken".
221
280
  async function activateLicense(token) {
222
- const result = await verifyLicenseToken({ token: String(token ?? '') });
281
+ const result = await verifyLicenseToken({ token: String(token ?? ''), publicJwk: LICENSE_PUBKEY_OVERRIDE });
223
282
  if (!result.valid) return { ok: false, error: `invalid license: ${result.reason}` };
224
283
  writeFileSync(licenseFile, String(token));
225
284
  licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
@@ -250,26 +309,32 @@ function signinResultPage(outcome) {
250
309
  `</script></body>`;
251
310
  }
252
311
 
253
- // "Currently occupying capacity" for the free-tier pending-goal cap every
254
- // status short of a genuinely finished/dismissed one. Deliberately broad for
255
- // the same reason RATE_LIMIT_RE above is broad: undercounting here would let
256
- // someone quietly exceed the cap via an odd status, which costs more than
257
- // occasionally counting a goal that's nearly done.
258
- const OPEN_GOAL_STATUSES = ['stacked', 'sending', 'pending', 'planning', 'needsInput', 'running', 'partial', 'failed', 'interrupted', 'blocked', 'review', 'retesting'];
259
-
260
- // NOTE(billing, needs a product decision): `projectCount` is left at 0 —
261
- // projects are shared/global in this codebase today (see the "Projects ...
262
- // stay shared for everyone" comment above goalVisibleTo in lib.mjs), not
263
- // per-identity, so a per-user "max 1 project" cap has nothing correct to
264
- // gate yet (an unpaid user's 2nd project would affect every other user, not
265
- // just them). checkFreeTierLimit already supports projectCount > 1 the
266
- // moment that's resolved see docs/BILLING-LAUNCH-PLAN.md.
312
+ // The 5-slot "queued to-dos" cap counts QUEUED_GOAL_STATUSES (from lib.mjs)
313
+ // every open status EXCEPT `review`. A goal in review has finished its work and
314
+ // is waiting on a human, so it isn't a to-do in flight, and galda.app sells the
315
+ // slot as "queued to-dos" (Masa 2026-07-17, "5枠は実行待ちだけ"). Still broad
316
+ // otherwise, for the same reason RATE_LIMIT_RE is: undercounting would let
317
+ // someone quietly exceed the cap via an odd status. The list lives in lib.mjs
318
+ // so the wall's meaning is defined next to checkFreeTierLimit and unit-tested.
319
+
320
+ // `projectCount` is install-wide, not per-identity: projects are shared/global
321
+ // in this codebase (see the "Projects ... stay shared for everyone" comment
322
+ // above goalVisibleTo in lib.mjs) while only goals are partitioned by owner.
323
+ // That asymmetry used to leave this at 0 pending a product decision (a per-user
324
+ // "max 1 project" cap would have let one user's 2nd project block everyone
325
+ // else's). Resolved 2026-07-17 (Masa): the shipping product is local-first —
326
+ // app.galda.app reaches each user's OWN engine over the relay, so one install
327
+ // is one user, and the license this gate consults (licenseState.email) is
328
+ // already install-wide too. Counting every project on the install is therefore
329
+ // the same scope billing already runs at. The stale caveat only applies to the
330
+ // legacy Cloudflare Access multi-user mode, where several identities share one
331
+ // install AND one license — there, a shared cap is already the status quo.
267
332
  function computeFreeTierUsage(identity) {
268
333
  const owned = goals.filter((g) => (g.owner ?? 'owner') === identity);
269
334
  return {
270
- pendingCount: owned.filter((g) => OPEN_GOAL_STATUSES.includes(g.status)).length,
335
+ pendingCount: owned.filter((g) => QUEUED_GOAL_STATUSES.includes(g.status)).length,
271
336
  cumulativeCount: owned.length,
272
- projectCount: 0,
337
+ projectCount: projects.length,
273
338
  };
274
339
  }
275
340
 
@@ -362,15 +427,25 @@ async function getBillingPrice() {
362
427
  // every installation (~/.claude/plans/toasty-rolling-marshmallow.md).
363
428
  // A personal/dev instance that wants to skip billing entirely should opt in
364
429
  // explicitly via MANAGER_ENTITLEMENT_BYPASS=1, not ride the 'owner' string.
365
- async function requireEntitlementGate(identity) {
430
+ async function requireEntitlementGate(identity, intent = 'goal') {
366
431
  if (process.env.MANAGER_ENTITLEMENT_BYPASS === '1') return { allowed: true, blocked: null };
367
- // Count the goal being created (existing + 1): computeFreeTierUsage counts
368
- // only goals already on file and this gate runs *before* the new one is
432
+ // Count the thing being created (existing + 1): computeFreeTierUsage counts
433
+ // only what's already on file and this gate runs *before* the new one is
369
434
  // added, so "5 pending / 19 lifetime max" must block the 6th / 20th create,
370
435
  // not the 7th / 21st. (checkFreeTierLimit keeps its `> limit` semantics; we
371
436
  // feed it the post-create count. Display in /api/state stays the raw count.)
437
+ //
438
+ // Only the dimension the caller is actually spending gets its real count;
439
+ // the others are zeroed. checkFreeTierLimit weighs all three at once and
440
+ // mirrors billing-api byte-for-byte, so it must not learn about intents —
441
+ // the caller narrows the question instead. Without this, an install already
442
+ // holding 2 projects (created before the project cap was enforced) would
443
+ // trip 'project-limit' on every *goal* create and lose a feature it had:
444
+ // caps are enforced at the moment you spend, never retroactively.
372
445
  const existing = computeFreeTierUsage(identity);
373
- const usage = { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1 };
446
+ const usage = intent === 'project'
447
+ ? { pendingCount: 0, cumulativeCount: 0, projectCount: existing.projectCount + 1 }
448
+ : { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1, projectCount: 0 };
374
449
  if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
375
450
  const billingEmail = licenseState.email;
376
451
  if (!billingEmail) { const r = resolveEntitlement({ isOwner: false, isPaying: false, usage }); noteFreeExhausted(r); return r; }
@@ -2952,9 +3027,10 @@ const server = createServer(async (req, res) => {
2952
3027
  // browser; Google bounces back to /oauth/callback (below) with a token.
2953
3028
  if (url.pathname === '/api/signin-url' && req.method === 'GET') {
2954
3029
  if (!BILLING_API_URL) return json(res, 501, { error: 'sign-in not configured (MANAGER_BILLING_API_URL unset)' });
2955
- const nonce = newSigninNonce();
2956
- const signinUrl = `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(nonce)}`;
2957
- return json(res, 200, { url: signinUrl });
3030
+ // Device-flow: no loopback port. buildSigninUrl mints {nonce,verifier},
3031
+ // returns the /connect URL carrying only the PKCE challenge, and kicks off
3032
+ // the background poll that claims the token from the Worker.
3033
+ return json(res, 200, { url: buildSigninUrl() });
2958
3034
  }
2959
3035
 
2960
3036
  // GET /oauth/callback?token&email&state | ?error&state — loopback landing for
@@ -3027,9 +3103,15 @@ const server = createServer(async (req, res) => {
3027
3103
  if (url.pathname === '/api/projects' && req.method === 'POST') {
3028
3104
  let body = '';
3029
3105
  req.on('data', (d) => { body += d; });
3030
- req.on('end', () => {
3106
+ req.on('end', async () => {
3031
3107
  try {
3032
3108
  const input = JSON.parse(body || '{}');
3109
+ // Billing (docs/BILLING-LAUNCH-PLAN.md §2/§5): a 2nd project is paid.
3110
+ // Same 402 shape as POST /api/tasks so the client has one branch to
3111
+ // handle. Gated here, on create, and nowhere else: projects that
3112
+ // already exist keep working on the free tier.
3113
+ const gate = await requireEntitlementGate(identityFor(req), 'project');
3114
+ if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
3033
3115
  const base = projects.find((p) => p.id === input.baseProjectId) ?? projects[0];
3034
3116
  const nameBase = String(input.name ?? 'New project').trim().slice(0, 80) || 'New project';
3035
3117
  let name = nameBase, n = 2;
@@ -4041,7 +4123,7 @@ server.listen(PORT, '127.0.0.1', () => {
4041
4123
  const signedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
4042
4124
  openUrl = signedIn
4043
4125
  ? APP_URL
4044
- : `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
4126
+ : buildSigninUrl(); // device-flow: also starts the background claim poll
4045
4127
  console.log(signedIn
4046
4128
  ? `[manager] opening your app → ${APP_URL}`
4047
4129
  : `[manager] ${FORCE_SIGNIN ? 'switching account' : 'first run'}: opening one-click sign-in → ${openUrl}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.27",
3
+ "version": "0.10.32",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {