@galda/cli 0.10.7 → 0.10.9

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.
@@ -86,6 +86,13 @@ process.env.MANAGER_OPEN_BROWSER = process.env.MANAGER_OPEN_BROWSER ?? '1';
86
86
  // and serves the whole API (/connect, /checkout, /api/*). See [[no-a2c-tech]].
87
87
  process.env.MANAGER_BILLING_API_URL = process.env.MANAGER_BILLING_API_URL ?? 'https://galda.app';
88
88
  process.env.RELAY_URL = process.env.RELAY_URL ?? 'wss://app.galda.app/agent';
89
+ // The named app the user actually works in (over the relay). The CLI opens THIS,
90
+ // not the localhost board, in the hosted flow. Override for a self-hosted app.
91
+ process.env.MANAGER_APP_URL = process.env.MANAGER_APP_URL ?? 'https://app.galda.app';
92
+ // `npx @galda/cli --signin` forces the Google sign-in even if a license already
93
+ // exists — the way to switch to a different account (a stale token otherwise
94
+ // binds you to the previous account).
95
+ if (process.argv.includes('--signin') || process.argv.includes('--login')) process.env.MANAGER_FORCE_SIGNIN = '1';
89
96
  // Choose a free port now and pin it for BOTH the server and the relay-client, so
90
97
  // a busy 4400 (another manager) no longer stops onboarding — no MANAGER_PORT by hand.
91
98
  const wantedPort = Number(process.env.MANAGER_PORT ?? 4400);
package/engine/lib.mjs CHANGED
@@ -2400,3 +2400,55 @@ export async function verifyLicenseToken({ token, publicJwk = LICENSE_PUBLIC_JWK
2400
2400
  export function shouldEmitSetupCompleted({ isMcpClient, alreadySeen, flagExists }) {
2401
2401
  return Boolean(isMcpClient) && !alreadySeen && !flagExists;
2402
2402
  }
2403
+
2404
+ // Onboarding funnel: a goal can only produce real changes if it runs inside a
2405
+ // real code repo. When the agent is started ad-hoc (e.g. `node bin/...` in the
2406
+ // user's HOME), the seeded default project points at a non-repo dir → the
2407
+ // worker no-ops (nothing to edit) yet the goal is marked "done" — the exact
2408
+ // blocker for "実装が始まる". This decides, BEFORE running, whether we must ask
2409
+ // the user which folder to work in. `isRepo` is passed in (the git probe is
2410
+ // I/O, done by the caller) so this stays pure/testable. Home is never a valid
2411
+ // work root — even a git-init'd home dir is "no project chosen yet".
2412
+ export function needsProjectFolder({ dir, homeDir, isRepo } = {}) {
2413
+ if (!dir) return true;
2414
+ const norm = (p) => String(p).replace(/\/+$/, '');
2415
+ if (homeDir && norm(dir) === norm(homeDir)) return true;
2416
+ return !isRepo;
2417
+ }
2418
+
2419
+ // A short, ≤60-char display label for a repo dir, shown home-relative (~/…) so
2420
+ // the folder-choice options read cleanly on the board card.
2421
+ export function folderLabel(dir, homeDir = '') {
2422
+ if (!dir) return '';
2423
+ let d = String(dir);
2424
+ const h = String(homeDir || '').replace(/\/+$/, '');
2425
+ if (h && (d === h || d.startsWith(h + '/'))) d = '~' + d.slice(h.length);
2426
+ return d.length <= 60 ? d : '…' + d.slice(-59);
2427
+ }
2428
+
2429
+ // Build the "which folder should I work in?" clarifying question from detected
2430
+ // repos. Reuses the existing needsInput card ({text, options:string[]}), and
2431
+ // adds kind:'folder' + folders[] + lang so the /answer handler rebinds the
2432
+ // goal's project to the chosen repo (instead of appending to goal.text like a
2433
+ // task clarification). `folders` = [{label, dir}]. Options are capped/shaped to
2434
+ // what the board expects (≤8 entries, each ≤60 chars) with a trailing
2435
+ // "somewhere else" escape for a typed path.
2436
+ export function buildFolderQuestion(folders = [], { lang = 'ja' } = {}) {
2437
+ const capped = folders.slice(0, 7).map((f) => ({ label: String(f.label || f.dir || ''), dir: f.dir }));
2438
+ const other = lang === 'en' ? 'Somewhere else…' : '別の場所を指定…';
2439
+ const text = lang === 'en' ? 'Which folder should I work in?' : 'どのフォルダで実装しますか?';
2440
+ return { text, options: [...capped.map((f) => f.label), other], folders: capped, kind: 'folder', lang, other };
2441
+ }
2442
+
2443
+ // Resolve a folder-choice answer (a picked option label, or a typed path) back
2444
+ // to an absolute dir. Returns { dir } or null (→ the "somewhere else" sentinel /
2445
+ // an unrecognized answer, which the caller re-asks on). Match by label first,
2446
+ // then exact dir, then treat an absolute-looking answer as a literal path.
2447
+ export function resolveFolderAnswer(answer, folders = []) {
2448
+ const a = String(answer ?? '').trim();
2449
+ if (!a) return null;
2450
+ const hit = folders.find((f) => f.label === a || f.dir === a);
2451
+ if (hit) return { dir: hit.dir };
2452
+ if (a.startsWith('/') || a.startsWith('~')) return { dir: a };
2453
+ return null;
2454
+ }
package/engine/server.mjs CHANGED
@@ -13,13 +13,14 @@
13
13
 
14
14
  import { createServer } from 'node:http';
15
15
  import { spawn, execFile, spawnSync } from 'node:child_process';
16
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
16
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync } from 'node:fs';
17
17
  import { emitEvent } from './analytics-client.mjs';
18
18
  import { randomBytes, createHash } from 'node:crypto';
19
19
  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 } from './lib.mjs';
23
24
  import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, 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, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted } from './lib.mjs';
24
25
  import { openPR } from './pr.mjs';
25
26
  import { runVerification, exerciseUi } from './verify.mjs';
@@ -53,7 +54,7 @@ function maybeEmitSetupCompleted(req) {
53
54
  _setupClientSeen = true;
54
55
  if (!shouldEmitSetupCompleted({ isMcpClient: true, alreadySeen: false, flagExists: existsSync(SETUP_FLAG) })) return;
55
56
  try { writeFileSync(SETUP_FLAG, String(Date.now())); } catch { /* best effort — still emit */ }
56
- emitEvent('signup', ANALYTICS_UID, { source: 'mcp-connect' });
57
+ emitEvent('signup', analyticsUid(), { source: 'mcp-connect' });
57
58
  }
58
59
  // Per-worker run cap. Env-configurable so it can be tuned operationally and the
59
60
  // timeout→'interrupted' path is testable (a test sets a short value + a slow
@@ -151,6 +152,15 @@ const MANAGER_OWNER_EMAIL = process.env.MANAGER_OWNER_EMAIL ?? '';
151
152
  // still works standalone (e.g. for Collaborators / dev) with no billing-api
152
153
  // configured at all.
153
154
  const BILLING_API_URL = (process.env.MANAGER_BILLING_API_URL ?? '').replace(/\/$/, '');
155
+ // The user's real UI in the hosted flow is the NAMED app (app.galda.app),
156
+ // reached over the relay — never the localhost board (Masa 2026-07-15:
157
+ // 「npm→ターミナル→app.galda.appに戻る / localhostでなくアプリ名で開くべき」).
158
+ // The localhost board stays the entry only for local/dev (no billing) runs.
159
+ const APP_URL = (process.env.MANAGER_APP_URL ?? '').replace(/\/$/, '');
160
+ // Force the sign-in flow even when a license already exists — so a user can
161
+ // switch to a different Google account (a stale token from a previous account
162
+ // otherwise silently binds the relay to the wrong account). Set by `--signin`.
163
+ const FORCE_SIGNIN = process.env.MANAGER_FORCE_SIGNIN === '1';
154
164
  const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
155
165
 
156
166
  // License token (local-first identity, docs/BILLING-LAUNCH-PLAN.md): a signed
@@ -165,6 +175,14 @@ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
165
175
  const licenseFile = join(DATA_DIR, 'license.token');
166
176
  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)
167
177
 
178
+ // Analytics identity (A+): once signed in, scope usage events to the ACCOUNT
179
+ // (the billing Worker hashes this email → the same account_id the accounts table
180
+ // carries, so per-user app activity joins WITHOUT a cookie). Not signed in →
181
+ // the anonymous per-install id. Raw email never lands in usage_events (the
182
+ // Worker pseudonymizes it server-side).
183
+ function analyticsUid() { return licenseState.email || ANALYTICS_UID; }
184
+ let _freeExhaustedEmitted = false;
185
+
168
186
  // One-time nonces for the "Sign in with Google" loopback (GET /api/signin-url →
169
187
  // billing /connect → Google → billing → GET /oauth/callback). The engine issues
170
188
  // the nonce, round-trips it through the flow, and requires it back — so a random
@@ -215,12 +233,17 @@ function signinResultPage(outcome) {
215
233
  const body = outcome.ok
216
234
  ? `<h1>Signed in</h1><p>You're signed in as <b>${esc(outcome.email)}</b>. This tab will close automatically.</p>`
217
235
  : `<h1>Sign-in didn't complete</h1><p>${esc(outcome.error)}</p><p class="muted">Close this tab and click “Sign in with Google” again in the app.</p>`;
236
+ // When the sign-in was opened FROM the app tab (window.opener present), tell
237
+ // it and close. When it was opened by the CLI at startup (no opener — `open`
238
+ // can't script-close it), don't strand the user on a localhost page: send them
239
+ // to the named app so the flow is npm → terminal → app.galda.app.
240
+ const goApp = outcome.ok && APP_URL ? JSON.stringify(APP_URL) : 'null';
218
241
  return `<!doctype html><meta charset="utf-8"><title>Galda</title>` +
219
242
  `<body style="font:15px/1.6 -apple-system,system-ui,sans-serif;max-width:32rem;margin:12vh auto;padding:0 1.5rem;color:#111">` +
220
243
  `<style>h1{font-size:1.35rem;margin:0 0 .5rem}.muted{color:#888;font-size:.9rem}b{font-weight:600}</style>` +
221
244
  body +
222
- `<script>try{if(window.opener)window.opener.postMessage(${payload},'*')}catch(e){}` +
223
- (outcome.ok ? `setTimeout(function(){try{window.close()}catch(e){}},1200);` : '') +
245
+ `<script>var opened=false;try{if(window.opener){window.opener.postMessage(${payload},'*');opened=true;}}catch(e){}` +
246
+ (outcome.ok ? `var app=${goApp};setTimeout(function(){if(opened){try{window.close()}catch(e){}}else if(app){location.replace(app)}else{try{window.close()}catch(e){}}},900);` : '') +
224
247
  `</script></body>`;
225
248
  }
226
249
 
@@ -347,7 +370,7 @@ async function requireEntitlementGate(identity) {
347
370
  const usage = { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1 };
348
371
  if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
349
372
  const billingEmail = licenseState.email;
350
- if (!billingEmail) return resolveEntitlement({ isOwner: false, isPaying: false, usage });
373
+ if (!billingEmail) { const r = resolveEntitlement({ isOwner: false, isPaying: false, usage }); noteFreeExhausted(r); return r; }
351
374
  const cachedState = resolveCachedEntitlement({ cached: entitlementCache.get(billingEmail) ?? null });
352
375
  let isPaying = cachedState.isPaying;
353
376
  if (cachedState.needsRefresh) {
@@ -357,7 +380,17 @@ async function requireEntitlementGate(identity) {
357
380
  entitlementCache.set(billingEmail, { isPaying: fresh, checkedAt: Date.now() });
358
381
  }
359
382
  }
360
- return resolveEntitlement({ isOwner: false, isPaying, usage });
383
+ const result = resolveEntitlement({ isOwner: false, isPaying, usage });
384
+ noteFreeExhausted(result);
385
+ return result;
386
+ }
387
+ // Emit one free_exhausted funnel event the first time a non-paying install is
388
+ // blocked by the free-tier cap (the funnel dedupes by distinct account/install,
389
+ // so once-per-session is enough).
390
+ function noteFreeExhausted(result) {
391
+ if (_freeExhaustedEmitted || !result?.blocked) return;
392
+ _freeExhaustedEmitted = true;
393
+ emitEvent('free_exhausted', analyticsUid());
361
394
  }
362
395
  // The acting identity for a request: 'owner' for key-auth or
363
396
  // MANAGER_OWNER_EMAIL, else the verified Google email itself. Stamped onto
@@ -428,6 +461,10 @@ async function authFromCfAccess(req) {
428
461
  }
429
462
  }
430
463
 
464
+ // Where to look for the user's repos (folder-choice options) and which dir is
465
+ // "not a real project" (never a valid work root). Defaults to the OS home;
466
+ // overridable for tests / operators whose repos live elsewhere.
467
+ const HOME = process.env.MANAGER_SCAN_HOME || homedir();
431
468
  const projectsFile = join(DATA_DIR, 'projects.json');
432
469
  if (!existsSync(projectsFile)) {
433
470
  const cwd = process.cwd();
@@ -573,12 +610,12 @@ function emitTaskAnalytics(t) {
573
610
  if (!t || t.id == null) return;
574
611
  if (!_analyticsEmitted.has(`created:${t.id}`)) {
575
612
  _analyticsEmitted.add(`created:${t.id}`);
576
- emitEvent('task_created', ANALYTICS_UID, { source: 'engine' });
613
+ emitEvent('task_created', analyticsUid(), { source: 'engine' });
577
614
  }
578
615
  const ev = _TERMINAL_EVENT[t.status];
579
616
  if (ev && !_analyticsEmitted.has(`${t.status}:${t.id}`)) {
580
617
  _analyticsEmitted.add(`${t.status}:${t.id}`);
581
- emitEvent(ev, ANALYTICS_UID, { status: t.status, source: 'engine' });
618
+ emitEvent(ev, analyticsUid(), { status: t.status, source: 'engine' });
582
619
  postHistory(t);
583
620
  }
584
621
  }
@@ -784,6 +821,30 @@ function repoDefaultBranch(dir) {
784
821
  try { return gitSync(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, ''); }
785
822
  catch { return 'main'; }
786
823
  }
824
+ // Non-throwing "is this a real git work tree?" probe. The onboarding guard uses
825
+ // it to refuse running a goal against a non-repo (which would no-op) and to
826
+ // validate a folder the user picks.
827
+ function isGitRepo(dir) {
828
+ try { return !!dir && existsSync(dir) && gitSync(dir, ['rev-parse', '--is-inside-work-tree']) === 'true'; }
829
+ catch { return false; }
830
+ }
831
+ // Shallow-scan the user's HOME for candidate repos to offer as folder choices
832
+ // (depth-1: direct children of HOME with a `.git` — matches how devs keep repos
833
+ // side by side, and stays fast/safe vs. a deep `find`). The typed-path escape
834
+ // in the folder question covers anything deeper.
835
+ function detectRepos(homeDir) {
836
+ const out = [];
837
+ try {
838
+ for (const name of readdirSync(homeDir)) {
839
+ if (name.startsWith('.')) continue;
840
+ const dir = join(homeDir, name);
841
+ try {
842
+ if (statSync(dir).isDirectory() && existsSync(join(dir, '.git'))) out.push({ dir, label: folderLabel(dir, homeDir) });
843
+ } catch { /* unreadable entry — skip */ }
844
+ }
845
+ } catch { /* unreadable home — return whatever we have */ }
846
+ return out.slice(0, 12);
847
+ }
787
848
  // Each goal works in its OWN git worktree, branched fresh from origin/<default>,
788
849
  // under <repo>/.manager-wt/goal-<id>. So: (1) the user's real working dir is
789
850
  // never touched; (2) each goal's diff contains only that goal's changes on top
@@ -2661,11 +2722,25 @@ const server = createServer(async (req, res) => {
2661
2722
  // no PR) — the reviewer just Approves (files it) or Dismisses (drops
2662
2723
  // it). This is how "needs my judgment" reaches the phone without kicking
2663
2724
  // off an implementation run.
2725
+ // Onboarding funnel (docs/HANDOFF-ONBOARDING-conversational.md): a real
2726
+ // implementation goal can only produce changes inside a real code repo.
2727
+ // If the active project points at a non-repo (e.g. the HOME dir when the
2728
+ // agent was started ad-hoc), running would no-op — nothing to edit — yet
2729
+ // report "done". Instead of that fake "fixed", ask which folder first,
2730
+ // reusing the needsInput clarifying card; the answer (POST /answer)
2731
+ // rebinds this goal to the chosen repo, then planning proceeds. Skipped
2732
+ // for pending/review items (they never spawn a worker).
2733
+ const runnable = !pending && !review;
2734
+ const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
2735
+ const folderQuestion = needsFolder
2736
+ ? buildFolderQuestion(detectRepos(HOME), { lang: /[぀-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en' })
2737
+ : undefined;
2664
2738
  const goalAgent = workerAgent(agent);
2665
2739
  const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
2666
2740
  const goal = {
2667
2741
  id: nextId++, projectId, text: text.trim().slice(0, 8000),
2668
- status: review ? 'review' : pending ? 'pending' : 'planning',
2742
+ status: needsFolder ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
2743
+ question: folderQuestion,
2669
2744
  reviewOnly: review ? true : undefined,
2670
2745
  note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
2671
2746
  wantsPR: review ? false : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
@@ -2695,7 +2770,7 @@ const server = createServer(async (req, res) => {
2695
2770
  // pending = deliberately shelved (Phase 2 material): never planned,
2696
2771
  // never queued, until POST /api/goals/:id/activate. review-only items
2697
2772
  // are terminal-until-human too, so they also skip planGoal.
2698
- if (!pending && !review) {
2773
+ if (runnable && !needsFolder) {
2699
2774
  planGoal(goal).catch((e) => {
2700
2775
  goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
2701
2776
  });
@@ -2994,6 +3069,34 @@ const server = createServer(async (req, res) => {
2994
3069
  let answer = '';
2995
3070
  try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
2996
3071
  if (!answer) return json(res, 400, { error: 'answer required' });
3072
+ // Folder-choice answer (onboarding funnel): rebind the goal to the chosen
3073
+ // repo rather than appending to goal.text — a folder isn't a task
3074
+ // clarification. Validate it's a real repo (and not HOME); if not, re-ask
3075
+ // with a fresh detected list so we never fall through to a no-op run.
3076
+ if (goal.question?.kind === 'folder') {
3077
+ const resolved = resolveFolderAnswer(answer, goal.question.folders || []);
3078
+ let dir = resolved?.dir || answer;
3079
+ if (dir.startsWith('~')) dir = join(HOME, dir.slice(1).replace(/^\/+/, ''));
3080
+ if (dir !== HOME && isGitRepo(dir)) {
3081
+ const existing = projects.find((p) => p.dir === dir);
3082
+ let proj = existing;
3083
+ if (!proj) {
3084
+ proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
3085
+ projects.push(proj); saveProjects();
3086
+ }
3087
+ goal.projectId = proj.id;
3088
+ goal.question = null;
3089
+ goal.clarified = true;
3090
+ goal.status = 'planning';
3091
+ saveGoal(goal);
3092
+ planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
3093
+ return json(res, 200, goal);
3094
+ }
3095
+ // not a usable repo → keep asking (don't run a no-op)
3096
+ goal.question = buildFolderQuestion(detectRepos(HOME), { lang: goal.question.lang || 'ja' });
3097
+ saveGoal(goal);
3098
+ return json(res, 200, goal);
3099
+ }
2997
3100
  goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
2998
3101
  goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
2999
3102
  goal.question = null;
@@ -3357,15 +3460,22 @@ server.on('error', (e) => {
3357
3460
  server.listen(PORT, '127.0.0.1', () => {
3358
3461
  console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
3359
3462
  if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
3360
- // First run in the hosted flow (no license yet, billing worker configured):
3361
- // open the one-click Google device-link straight away instead of the local
3362
- // ?key board, so a non-engineer never sees a localhost URL or an access key
3363
- // they sign in once and their hosted board comes alive (relay-client rebinds
3364
- // via its license.token watcher). Otherwise open the local board as before.
3463
+ // Hosted flow (billing worker + named app configured): the user lives in
3464
+ // app.galda.app, driven over the relay they must NEVER be dropped on a
3465
+ // localhost board or an access key (Masa 2026-07-15: 「npm→ターミナル→
3466
+ // app.galda.appに戻る」). Not signed in (or switching accounts via --signin)
3467
+ // one-click Google sign-in; already signed in straight to the app (the
3468
+ // board comes alive as relay-client rebinds via its license.token watcher).
3469
+ // The localhost ?key board stays the entry ONLY for local/dev (no billing).
3365
3470
  let openUrl = `http://localhost:${PORT}/?key=${ACCESS_KEY}`;
3366
- if (BILLING_API_URL && !existsSync(licenseFile)) {
3367
- openUrl = `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
3368
- console.log('[manager] first run: opening one-click sign-in (no local key needed) → ' + openUrl);
3471
+ if (BILLING_API_URL && APP_URL) {
3472
+ const signedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
3473
+ openUrl = signedIn
3474
+ ? APP_URL
3475
+ : `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
3476
+ console.log(signedIn
3477
+ ? `[manager] opening your app → ${APP_URL}`
3478
+ : `[manager] ${FORCE_SIGNIN ? 'switching account' : 'first run'}: opening one-click sign-in → ${openUrl}`);
3369
3479
  }
3370
3480
  spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
3371
3481
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.7",
3
+ "version": "0.10.9",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to your Claude Code, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {