happy-stacks 0.6.12 → 0.6.13

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.
Files changed (59) hide show
  1. package/docs/commit-audits/happy/_tools/generate-plans.mjs +453 -0
  2. package/docs/commit-audits/happy/_tools/generate-pr-assignment.mjs +430 -0
  3. package/docs/commit-audits/happy/_tools/init-pr-assignment-working.mjs +107 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +1849 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +747 -1
  6. package/docs/commit-audits/happy/leeroy-wip.commit-index.json +11740 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-index.tsv +252 -0
  8. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +18 -11
  9. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1236 -92
  10. package/docs/commit-audits/happy/leeroy-wip.maintainers-overview.draft.md +448 -0
  11. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.draft.tsv +252 -0
  12. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.working.tsv +288 -0
  13. package/docs/commit-audits/happy/leeroy-wip.pr-catalog.draft.md +245 -0
  14. package/docs/commit-audits/happy/leeroy-wip.pr-stack-plan.draft.md +350 -0
  15. package/docs/commit-audits/happy/leeroy-wip.rewrite-deferred-fragments.tsv +65 -0
  16. package/docs/commit-audits/happy/leeroy-wip.rewrite-ledger.tsv +56 -0
  17. package/docs/commit-audits/happy/leeroy-wip.rewrite-process.md +240 -0
  18. package/docs/commit-audits/happy/leeroy-wip.rewrite-status.tsv +39 -0
  19. package/docs/commit-audits/happy/leeroy-wip.split-plan.draft.md +93 -0
  20. package/docs/commit-audits/happy/leeroy-wip.topic-buckets.md +76 -0
  21. package/docs/commit-audits/happy/pr-desc.extraction-ledger.tsv +279 -0
  22. package/docs/commit-audits/happy/pr-desc.original.md +0 -0
  23. package/docs/commit-audits/happy/pr-desc.post-audit-extraction-ledger.tsv +54 -0
  24. package/docs/commit-audits/happy/pr-desc.working-document.md +536 -0
  25. package/docs/happy-development.md +18 -1
  26. package/docs/isolated-linux-vm.md +23 -1
  27. package/docs/stacks.md +21 -1
  28. package/package.json +1 -1
  29. package/scripts/auth.mjs +46 -8
  30. package/scripts/daemon.mjs +44 -21
  31. package/scripts/doctor.mjs +2 -2
  32. package/scripts/doctor_cmd.test.mjs +67 -0
  33. package/scripts/happy.mjs +18 -5
  34. package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
  35. package/scripts/provision/macos-lima-happy-vm.sh +34 -2
  36. package/scripts/review.mjs +347 -124
  37. package/scripts/review_pr.mjs +78 -2
  38. package/scripts/run.mjs +2 -1
  39. package/scripts/stack.mjs +265 -19
  40. package/scripts/stack_daemon_cmd.test.mjs +196 -0
  41. package/scripts/stack_happy_cmd.test.mjs +103 -0
  42. package/scripts/utils/cli/prereqs.mjs +12 -1
  43. package/scripts/utils/dev/daemon.mjs +3 -1
  44. package/scripts/utils/proc/pm.mjs +1 -1
  45. package/scripts/utils/review/detached_worktree.mjs +61 -0
  46. package/scripts/utils/review/detached_worktree.test.mjs +62 -0
  47. package/scripts/utils/review/findings.mjs +133 -20
  48. package/scripts/utils/review/findings.test.mjs +88 -1
  49. package/scripts/utils/review/runners/augment.mjs +71 -0
  50. package/scripts/utils/review/runners/augment.test.mjs +42 -0
  51. package/scripts/utils/review/runners/coderabbit.mjs +54 -10
  52. package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
  53. package/scripts/utils/review/sliced_runner.mjs +39 -0
  54. package/scripts/utils/review/sliced_runner.test.mjs +47 -0
  55. package/scripts/utils/review/tool_home_seed.mjs +99 -0
  56. package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
  57. package/scripts/utils/stack/cli_identities.mjs +29 -0
  58. package/scripts/utils/stack/startup.mjs +45 -7
  59. package/scripts/worktrees.mjs +8 -5
@@ -22,6 +22,13 @@ function usage() {
22
22
  '[review-pr] usage:',
23
23
  ' happys review-pr --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--name=<stack>] [--dev|--start] [--mobile|--no-mobile] [--forks|--upstream] [--seed-auth|--no-seed-auth] [--copy-auth-from=<stack>] [--link-auth|--copy-auth] [--update] [--force] [--keep-sandbox] [--json] [-- <stack dev/start args...>]',
24
24
  '',
25
+ 'VM port forwarding (optional):',
26
+ '- `--vm-ports`: convenience preset for port-forwarded VMs (stack ports ~13xxx, Expo ports ~18xxx)',
27
+ '- `--stack-port-start=<n>`: sets HAPPY_STACKS_STACK_PORT_START inside the sandbox',
28
+ '- `--expo-dev-port-strategy=stable|ephemeral`: sets HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY inside the sandbox',
29
+ '- `--expo-dev-port-base=<n>` / `--expo-dev-port-range=<n>`: stable Expo port hashing params',
30
+ '- `--expo-dev-port=<n>`: force the Expo dev (Metro) port inside the sandbox',
31
+ '',
25
32
  'What it does:',
26
33
  '- creates a temporary sandbox dir',
27
34
  '- runs `happys setup-pr ...` inside that sandbox (fully isolated state)',
@@ -70,6 +77,64 @@ function kvValue(argv, names) {
70
77
  return null;
71
78
  }
72
79
 
80
+ function stripArgv(argv, names) {
81
+ const out = [];
82
+ for (const a of argv) {
83
+ let keep = true;
84
+ for (const n of names) {
85
+ if (a === n || a.startsWith(`${n}=`)) {
86
+ keep = false;
87
+ break;
88
+ }
89
+ }
90
+ if (keep) out.push(a);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function resolveSandboxPortEnvOverrides(argv) {
96
+ const overrides = {};
97
+
98
+ // Convenience preset for VM review flows (pairs with Lima port-forward ranges in docs).
99
+ if (argvHasFlag(argv, ['--vm-ports'])) {
100
+ overrides.HAPPY_STACKS_STACK_PORT_START = '13005';
101
+ overrides.HAPPY_LOCAL_STACK_PORT_START = '13005';
102
+
103
+ // Keep Expo dev ports stable per stack so forwarded ports remain predictable.
104
+ overrides.HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY = 'stable';
105
+ overrides.HAPPY_STACKS_EXPO_DEV_PORT_BASE = '18081';
106
+ overrides.HAPPY_STACKS_EXPO_DEV_PORT_RANGE = '1000';
107
+ }
108
+
109
+ const stackPortStart = (kvValue(argv, ['--stack-port-start']) ?? '').trim();
110
+ if (stackPortStart) {
111
+ overrides.HAPPY_STACKS_STACK_PORT_START = stackPortStart;
112
+ overrides.HAPPY_LOCAL_STACK_PORT_START = stackPortStart;
113
+ }
114
+
115
+ const expoStrategy = (kvValue(argv, ['--expo-dev-port-strategy']) ?? '').trim().toLowerCase();
116
+ if (expoStrategy === 'stable' || expoStrategy === 'ephemeral') {
117
+ overrides.HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY = expoStrategy;
118
+ }
119
+
120
+ const expoBase = (kvValue(argv, ['--expo-dev-port-base']) ?? '').trim();
121
+ if (expoBase) {
122
+ overrides.HAPPY_STACKS_EXPO_DEV_PORT_BASE = expoBase;
123
+ }
124
+
125
+ const expoRange = (kvValue(argv, ['--expo-dev-port-range']) ?? '').trim();
126
+ if (expoRange) {
127
+ overrides.HAPPY_STACKS_EXPO_DEV_PORT_RANGE = expoRange;
128
+ }
129
+
130
+ const expoForced = (kvValue(argv, ['--expo-dev-port']) ?? '').trim();
131
+ if (expoForced) {
132
+ overrides.HAPPY_STACKS_EXPO_DEV_PORT = expoForced;
133
+ }
134
+
135
+ return Object.keys(overrides).length ? overrides : null;
136
+ }
137
+
73
138
  async function main() {
74
139
  const rootDir = getRootDir(import.meta.url);
75
140
  const argv = process.argv.slice(2);
@@ -244,9 +309,20 @@ async function main() {
244
309
  const hasNameFlag = argvWithDefaults.some((a) => a === '--name' || a.startsWith('--name='));
245
310
  const argvFinal = hasNameFlag ? argvWithDefaults : [...argvWithDefaults, `--name=${effectiveStackName}`];
246
311
 
247
- child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'setup-pr', ...argvFinal], {
312
+ // Sandbox-only port overrides (useful for VM testing where host port-forwarding expects specific ranges).
313
+ const portEnv = resolveSandboxPortEnvOverrides(argvFinal);
314
+ const argvForSetupPr = stripArgv(argvFinal, [
315
+ '--vm-ports',
316
+ '--stack-port-start',
317
+ '--expo-dev-port-strategy',
318
+ '--expo-dev-port-base',
319
+ '--expo-dev-port-range',
320
+ '--expo-dev-port',
321
+ ]);
322
+
323
+ child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'setup-pr', ...argvForSetupPr], {
248
324
  cwd: rootDir,
249
- env: process.env,
325
+ env: portEnv ? { ...process.env, ...portEnv } : process.env,
250
326
  stdio: 'inherit',
251
327
  });
252
328
 
package/scripts/run.mjs CHANGED
@@ -271,11 +271,12 @@ async function main() {
271
271
  : `file:${join(dataDir, 'happy-server-light.sqlite')}`;
272
272
 
273
273
  // Reliability: ensure DB schema exists before daemon hits /v1/machines (health checks don't cover DB readiness).
274
+ // If the server is already running and we are not restarting, do NOT run migrations here (SQLite can lock).
274
275
  const acct = await getAccountCountForServerComponent({
275
276
  serverComponentName,
276
277
  serverDir,
277
278
  env: serverEnv,
278
- bestEffort: false,
279
+ bestEffort: Boolean(serverAlreadyRunning && !restart),
279
280
  });
280
281
  serverLightAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
281
282
  }
package/scripts/stack.mjs CHANGED
@@ -2,7 +2,7 @@ import './utils/env/env.mjs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { chmod, copyFile, mkdir, open, readFile, readdir, rename, writeFile } from 'node:fs/promises';
4
4
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
- import { existsSync } from 'node:fs';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
6
  // NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
7
7
  import { homedir } from 'node:os';
8
8
  import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs';
@@ -64,6 +64,7 @@ import {
64
64
  } from './utils/stack/runtime_state.mjs';
65
65
  import { killPid } from './utils/expo/expo.mjs';
66
66
  import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
67
+ import { parseCliIdentityOrThrow, resolveCliHomeDirForIdentity } from './utils/stack/cli_identities.mjs';
67
68
  import { randomToken } from './utils/crypto/tokens.mjs';
68
69
  import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
69
70
  import { sanitizeSlugPart } from './utils/git/refs.mjs';
@@ -1432,10 +1433,12 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1432
1433
  return;
1433
1434
  }
1434
1435
 
1436
+ let exit = { code: null, sig: null, ok: false };
1435
1437
  try {
1436
1438
  await new Promise((resolvePromise, rejectPromise) => {
1437
1439
  child.on('error', rejectPromise);
1438
1440
  child.on('exit', (code, sig) => {
1441
+ exit = { code: code ?? null, sig: sig ?? null, ok: code === 0 };
1439
1442
  if (code === 0) return resolvePromise();
1440
1443
  return rejectPromise(new Error(`stack ${scriptPath} exited (code=${code ?? 'null'}, sig=${sig ?? 'null'})`));
1441
1444
  });
@@ -1443,7 +1446,26 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1443
1446
  } finally {
1444
1447
  const cur = await readStackRuntimeStateFile(runtimeStatePath);
1445
1448
  if (Number(cur?.ownerPid) === Number(child.pid)) {
1446
- await deleteStackRuntimeStateFile(runtimeStatePath);
1449
+ // Only delete runtime state when we're confident no child processes are left behind.
1450
+ // If the runner crashes but a child (server/expo/daemon) stays alive, keeping stack.runtime.json
1451
+ // allows `happys stack stop --aggressive` to kill the recorded PIDs safely.
1452
+ const processes = cur?.processes && typeof cur.processes === 'object' ? cur.processes : {};
1453
+ const anyAlive = Object.values(processes)
1454
+ .map((p) => Number(p))
1455
+ .some((pid) => Number.isFinite(pid) && pid > 1 && isPidAlive(pid));
1456
+ const portRaw = cur?.ports && typeof cur.ports === 'object' ? cur.ports.server : null;
1457
+ const port = Number(portRaw);
1458
+ const portOccupied =
1459
+ Number.isFinite(port) && port > 0 ? !(await isTcpPortFree(port, { host: '127.0.0.1' }).catch(() => true)) : false;
1460
+
1461
+ if (!anyAlive && !portOccupied) {
1462
+ await deleteStackRuntimeStateFile(runtimeStatePath);
1463
+ } else if (!wantsJson) {
1464
+ console.warn(
1465
+ `[stack] ${stackName}: preserving ${runtimeStatePath} after runner exit (child processes still alive). ` +
1466
+ `Run: happys stack stop ${stackName} --yes --aggressive`
1467
+ );
1468
+ }
1447
1469
  }
1448
1470
  }
1449
1471
  return;
@@ -3129,7 +3151,7 @@ async function cmdPrStack({ rootDir, argv }) {
3129
3151
 
3130
3152
  // Monorepo shortcut:
3131
3153
  // If `--happy=<pr>` was provided and the local checkout is the slopus/happy monorepo, pin
3132
- // happy-cli and (optionally) happy-server to that same worktree without fetching separate PRs.
3154
+ // happy-cli and server flavors to that same worktree without fetching separate PRs.
3133
3155
  if (happyMonorepoActive && prHappy) {
3134
3156
  const happyWt = worktrees.find((w) => w?.component === 'happy');
3135
3157
  const happyPath = String(happyWt?.path ?? '').trim();
@@ -3147,11 +3169,26 @@ async function cmdPrStack({ rootDir, argv }) {
3147
3169
  const derivedComponents = [
3148
3170
  'happy-cli',
3149
3171
  ...(serverComponent === 'happy-server' ? ['happy-server'] : []),
3172
+ // If the user didn't explicitly provide a separate server-light PR, prefer the monorepo server/ dir.
3173
+ ...(serverComponent === 'happy-server-light' && !prServerLight ? ['happy-server-light'] : []),
3150
3174
  ];
3151
3175
 
3152
3176
  for (const c of derivedComponents) {
3153
3177
  const p = derive(c);
3154
3178
  if (!p) continue;
3179
+ if (c === 'happy-server-light') {
3180
+ const hasSqliteSchema =
3181
+ existsSync(join(p, 'prisma', 'sqlite', 'schema.prisma')) || existsSync(join(p, 'prisma', 'schema.sqlite.prisma'));
3182
+ if (!hasSqliteSchema) {
3183
+ throw new Error(
3184
+ '[stack] pr: happy-server-light was requested, but the monorepo server checkout does not include sqlite schema support.\n' +
3185
+ `- expected one of:\n` +
3186
+ ` - ${join(p, 'prisma', 'sqlite', 'schema.prisma')}\n` +
3187
+ ` - ${join(p, 'prisma', 'schema.sqlite.prisma')}\n` +
3188
+ 'Fix: either switch to the full server flavor (--server=happy-server), or provide an explicit --happy-server-light=<pr>.'
3189
+ );
3190
+ }
3191
+ }
3155
3192
  if (!isComponentWorktreePath({ rootDir, component: c, dir: p, env: process.env })) {
3156
3193
  throw new Error(`[stack] pr: refusing to pin ${c} because the derived path is not a worktree: ${p}`);
3157
3194
  }
@@ -3376,27 +3413,30 @@ async function cmdStackOpen({ rootDir, stackName, json, includeStackDir, include
3376
3413
  }
3377
3414
 
3378
3415
  async function cmdStackDaemon({ rootDir, stackName, argv, json }) {
3379
- const { flags } = parseArgs(argv);
3416
+ const { flags, kv } = parseArgs(argv);
3380
3417
  const wantsHelpFlag = wantsHelp(argv, { flags });
3381
3418
 
3382
3419
  const positionals = argv.filter((a) => a && a !== '--' && !a.startsWith('--'));
3383
3420
  const action = (positionals[0] ?? 'status').toString().trim();
3421
+ const identity = parseCliIdentityOrThrow((kv.get('--identity') ?? '').trim());
3422
+ const noOpen = flags.has('--no-open') || flags.has('--no-browser') || flags.has('--no-browser-open');
3384
3423
 
3385
3424
  if (wantsHelpFlag || !action || action === 'help') {
3386
3425
  printResult({
3387
3426
  json,
3388
- data: { ok: true, stackName, commands: ['start', 'stop', 'restart', 'status'] },
3427
+ data: { ok: true, stackName, commands: ['start', 'stop', 'restart', 'status'], flags: ['--identity=<name>'] },
3389
3428
  text: [
3390
3429
  banner('stack daemon', { subtitle: `Manage the happy-cli daemon for stack ${cyan(stackName || 'main')}.` }),
3391
3430
  '',
3392
3431
  sectionTitle('usage:'),
3393
- ` ${cyan('happys stack daemon')} <name> status [--json]`,
3394
- ` ${cyan('happys stack daemon')} <name> start [--json]`,
3395
- ` ${cyan('happys stack daemon')} <name> stop [--json]`,
3396
- ` ${cyan('happys stack daemon')} <name> restart [--json]`,
3432
+ ` ${cyan('happys stack daemon')} <name> status [--identity=<name>] [--json]`,
3433
+ ` ${cyan('happys stack daemon')} <name> start [--identity=<name>] [--json]`,
3434
+ ` ${cyan('happys stack daemon')} <name> stop [--identity=<name>] [--json]`,
3435
+ ` ${cyan('happys stack daemon')} <name> restart [--identity=<name>] [--json]`,
3397
3436
  '',
3398
3437
  sectionTitle('example:'),
3399
3438
  ` ${cmdFmt(`happys stack daemon ${stackName || 'main'} restart`)}`,
3439
+ ` ${cmdFmt(`happys stack daemon ${stackName || 'main'} start --identity=account-b`)}`,
3400
3440
  ].join('\n'),
3401
3441
  });
3402
3442
  return;
@@ -3423,37 +3463,106 @@ async function cmdStackDaemon({ rootDir, stackName, argv, json }) {
3423
3463
  (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() ||
3424
3464
  getComponentDir(rootDir, 'happy-cli');
3425
3465
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
3426
- const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ??
3466
+ const baseCliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ??
3427
3467
  env.HAPPY_LOCAL_CLI_HOME_DIR ??
3428
3468
  join(resolveStackEnvPath(stackName).baseDir, 'cli')).toString();
3469
+ const cliHomeDir = resolveCliHomeDirForIdentity({ cliHomeDir: baseCliHomeDir, identity });
3429
3470
  const serverPort = resolveServerPortFromEnv({ env, defaultPort: 3005 });
3430
3471
  const urls = await resolveServerUrls({ env, serverPort, allowEnable: false });
3431
3472
  const internalServerUrl = urls.internalServerUrl;
3432
3473
  const publicServerUrl = urls.publicServerUrl;
3433
- const daemonEnv = getDaemonEnv({ baseEnv: env, cliHomeDir, internalServerUrl, publicServerUrl });
3474
+ const envForIdentity = {
3475
+ ...env,
3476
+ HAPPY_STACKS_CLI_IDENTITY: identity,
3477
+ HAPPY_LOCAL_CLI_IDENTITY: identity,
3478
+ ...(identity !== 'default'
3479
+ ? {
3480
+ HAPPY_STACKS_MIGRATE_CREDENTIALS: '0',
3481
+ HAPPY_LOCAL_MIGRATE_CREDENTIALS: '0',
3482
+ HAPPY_STACKS_AUTO_AUTH_SEED: '0',
3483
+ HAPPY_LOCAL_AUTO_AUTH_SEED: '0',
3484
+ }
3485
+ : {}),
3486
+ };
3487
+ await mkdir(cliHomeDir, { recursive: true }).catch(() => {});
3488
+ const daemonEnv = getDaemonEnv({ baseEnv: envForIdentity, cliHomeDir, internalServerUrl, publicServerUrl });
3434
3489
 
3435
3490
  if (action === 'start' || action === 'restart') {
3491
+ // UX: if this identity is not authenticated yet and we're in a real TTY, offer to run the
3492
+ // guided login flow inline (instead of failing or asking for a second terminal).
3493
+ //
3494
+ // Important: never prompt in --json mode (automation must not hang).
3495
+ const accessKeyPath = join(cliHomeDir, 'access.key');
3496
+ const hasCreds = (() => {
3497
+ try {
3498
+ if (!existsSync(accessKeyPath)) return false;
3499
+ return readFileSync(accessKeyPath, 'utf-8').trim().length > 0;
3500
+ } catch {
3501
+ return false;
3502
+ }
3503
+ })();
3504
+
3505
+ if (!hasCreds) {
3506
+ if (json) {
3507
+ const loginCmd = `happys stack auth ${stackName} login${identity !== 'default' ? ` --identity=${identity} --no-open` : ''}`;
3508
+ return { ok: false, action, error: 'auth_required', cliIdentity: identity, cliHomeDir, loginCmd };
3509
+ }
3510
+
3511
+ if (isTty()) {
3512
+ const choice = await withRl(async (rl) => {
3513
+ return await promptSelect(rl, {
3514
+ title:
3515
+ `Daemon identity "${identity}" is not authenticated yet.\n` +
3516
+ `Authenticate now? (recommended)\n`,
3517
+ options: [
3518
+ { label: 'yes (run guided login now)', value: 'yes' },
3519
+ { label: 'no (show command and exit)', value: 'no' },
3520
+ ],
3521
+ defaultIndex: 0,
3522
+ });
3523
+ });
3524
+
3525
+ if (choice === 'yes') {
3526
+ const authArgs = [
3527
+ 'login',
3528
+ ...(identity !== 'default' ? [`--identity=${identity}`] : []),
3529
+ ...(identity !== 'default' || noOpen ? ['--no-open'] : []),
3530
+ ];
3531
+ await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...authArgs], {
3532
+ cwd: rootDir,
3533
+ env: envForIdentity,
3534
+ stdio: 'inherit',
3535
+ });
3536
+ } else {
3537
+ const loginCmd = `happys stack auth ${stackName} login${identity !== 'default' ? ` --identity=${identity} --no-open` : ''}`;
3538
+ throw new Error(`[stack] daemon auth required. Run:\n${loginCmd}`);
3539
+ }
3540
+ }
3541
+ }
3542
+
3436
3543
  await startLocalDaemonWithAuth({
3437
3544
  cliBin,
3438
3545
  cliHomeDir,
3439
3546
  internalServerUrl,
3440
3547
  publicServerUrl,
3548
+ isShuttingDown: () => false,
3441
3549
  forceRestart: action === 'restart',
3442
- env,
3550
+ env: envForIdentity,
3443
3551
  stackName,
3552
+ cliIdentity: identity,
3444
3553
  });
3445
3554
  const status = await runCapture(process.execPath, [cliBin, 'daemon', 'status'], { cwd: rootDir, env: daemonEnv });
3446
- return { ok: true, action, status: status.trim() };
3555
+ return { ok: true, action, cliIdentity: identity, cliHomeDir, status: status.trim() };
3447
3556
  }
3448
3557
 
3449
3558
  if (action === 'stop') {
3450
3559
  await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
3451
3560
  const status = await runCapture(process.execPath, [cliBin, 'daemon', 'status'], { cwd: rootDir, env: daemonEnv }).catch(() => '');
3452
- return { ok: true, action, status: status.trim() || null };
3561
+ return { ok: true, action, cliIdentity: identity, cliHomeDir, status: status.trim() || null };
3453
3562
  }
3454
3563
 
3455
3564
  const status = await runCapture(process.execPath, [cliBin, 'daemon', 'status'], { cwd: rootDir, env: daemonEnv });
3456
- return { ok: true, action, status: status.trim() };
3565
+ return { ok: true, action, cliIdentity: identity, cliHomeDir, status: status.trim() };
3457
3566
  },
3458
3567
  });
3459
3568
 
@@ -3472,12 +3581,91 @@ async function cmdStackDaemon({ rootDir, stackName, argv, json }) {
3472
3581
  console.log(`${green('✓')} daemon command completed`);
3473
3582
  }
3474
3583
 
3584
+ const STACK_NAME_FIRST_SUPPORTED_COMMANDS = new Set([
3585
+ 'help',
3586
+ 'new',
3587
+ 'edit',
3588
+ 'list',
3589
+ 'migrate',
3590
+ 'audit',
3591
+ 'archive',
3592
+ 'duplicate',
3593
+ 'info',
3594
+ 'pr',
3595
+ 'create-dev-auth-seed',
3596
+ 'daemon',
3597
+ 'happy',
3598
+ 'env',
3599
+ 'auth',
3600
+ 'dev',
3601
+ 'start',
3602
+ 'build',
3603
+ 'review',
3604
+ 'typecheck',
3605
+ 'lint',
3606
+ 'test',
3607
+ 'doctor',
3608
+ 'mobile',
3609
+ 'mobile:install',
3610
+ 'mobile-dev-client',
3611
+ 'resume',
3612
+ 'stop',
3613
+ 'code',
3614
+ 'cursor',
3615
+ 'open',
3616
+ 'srv',
3617
+ 'wt',
3618
+ 'service',
3619
+ ]);
3620
+
3621
+ function isKnownStackCommandToken(token) {
3622
+ const t = (token ?? '').toString().trim();
3623
+ if (!t) return false;
3624
+ if (t.startsWith('service:')) return true;
3625
+ if (t.startsWith('tailscale:')) return true;
3626
+ return STACK_NAME_FIRST_SUPPORTED_COMMANDS.has(t);
3627
+ }
3628
+
3629
+ function normalizeStackNameFirstArgs(argv) {
3630
+ // Back-compat UX:
3631
+ // Allow `happys stack <name> <command> ...` (stack name first) as a shortcut for:
3632
+ // `happys stack <command> <name> ...`
3633
+ //
3634
+ // We only apply this rewrite when the first positional is *not* a known stack subcommand,
3635
+ // but *is* an existing stack name.
3636
+ const args = Array.isArray(argv) ? argv : [];
3637
+ const positionalIdx = [];
3638
+ for (let i = 0; i < args.length; i++) {
3639
+ const a = args[i];
3640
+ if (!a) continue;
3641
+ if (a === '--') continue;
3642
+ if (a.startsWith('-')) continue;
3643
+ positionalIdx.push(i);
3644
+ if (positionalIdx.length >= 2) break;
3645
+ }
3646
+ if (positionalIdx.length < 2) return args;
3647
+
3648
+ const [i0, i1] = positionalIdx;
3649
+ const first = args[i0];
3650
+ const second = args[i1];
3651
+
3652
+ if (isKnownStackCommandToken(first)) return args;
3653
+ if (!isKnownStackCommandToken(second)) return args;
3654
+ if (!stackExistsSync(first)) return args;
3655
+
3656
+ const next = [...args];
3657
+ next[i0] = second;
3658
+ next[i1] = first;
3659
+ return next;
3660
+ }
3661
+
3475
3662
  async function main() {
3476
3663
  const rootDir = getRootDir(import.meta.url);
3477
3664
  // pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
3478
3665
  // positional slicing behaves consistently.
3479
3666
  const rawArgv = process.argv.slice(2);
3480
- const argv = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv;
3667
+ const argv0 = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv;
3668
+ const argv = normalizeStackNameFirstArgs(argv0);
3481
3669
 
3482
3670
  const { flags } = parseArgs(argv);
3483
3671
  const positionals = argv.filter((a) => !a.startsWith('--'));
@@ -3744,11 +3932,65 @@ async function main() {
3744
3932
  return;
3745
3933
  }
3746
3934
  if (cmd === 'happy') {
3747
- const args = passthrough[0] === '--' ? passthrough.slice(1) : passthrough;
3935
+ // Allow stack-scoped CLI identity selection:
3936
+ // - `happys stack happy <name> --identity=account-a -- <happy-cli args...>`
3937
+ // - (no passthrough args) `happys stack happy <name> --identity=account-a`
3938
+ //
3939
+ // Implementation detail: we set HAPPY_HOME_DIR (highest precedence) so anything that uses
3940
+ // the CLI home dir (credentials, daemon control, logs, etc.) uses the selected identity.
3941
+ const sepIdx = passthrough.indexOf('--');
3942
+ const wrapperArgs = sepIdx === -1 ? passthrough : passthrough.slice(0, sepIdx);
3943
+ const forwardedArgsRaw = sepIdx === -1 ? passthrough : passthrough.slice(sepIdx + 1);
3944
+
3945
+ // If there is no explicit `--`, treat `--identity=...` tokens as wrapper flags (since there are no
3946
+ // unambiguous happy-cli args to separate).
3947
+ const { kv } = parseArgs(wrapperArgs);
3948
+ const identityRaw = (kv.get('--identity') ?? '').toString().trim();
3949
+ const identity = identityRaw ? parseCliIdentityOrThrow(identityRaw) : null;
3950
+
3951
+ const forwardedArgs =
3952
+ sepIdx === -1
3953
+ ? forwardedArgsRaw.filter((a) => !(identity && typeof a === 'string' && a.trim().startsWith('--identity=')))
3954
+ : forwardedArgsRaw;
3955
+
3748
3956
  await withStackEnv({
3749
3957
  stackName,
3750
3958
  fn: async ({ env }) => {
3751
- await run(process.execPath, [join(rootDir, 'scripts', 'happy.mjs'), ...args], { cwd: rootDir, env });
3959
+ // NOTE: resolve cli home using the *stack env* we just loaded, not the outer process env.
3960
+ // If identity is set, prefer our explicit HAPPY_HOME_DIR override.
3961
+ const baseCliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ??
3962
+ env.HAPPY_LOCAL_CLI_HOME_DIR ??
3963
+ join(resolveStackEnvPath(stackName).baseDir, 'cli')).toString();
3964
+ const cliHomeDirForIdentity = identity
3965
+ ? resolveCliHomeDirForIdentity({ cliHomeDir: baseCliHomeDir, identity })
3966
+ : baseCliHomeDir;
3967
+ const envForHappy = identity
3968
+ ? {
3969
+ ...env,
3970
+ HAPPY_STACKS_CLI_IDENTITY: identity,
3971
+ HAPPY_LOCAL_CLI_IDENTITY: identity,
3972
+ // Highest-precedence signal for happy-cli: identity-scoped home dir.
3973
+ HAPPY_HOME_DIR: cliHomeDirForIdentity,
3974
+ // Keep stack helpers consistent too (some scripts use *_CLI_HOME_DIR).
3975
+ HAPPY_STACKS_CLI_HOME_DIR: cliHomeDirForIdentity,
3976
+ HAPPY_LOCAL_CLI_HOME_DIR: cliHomeDirForIdentity,
3977
+ }
3978
+ : env;
3979
+
3980
+ // Passthrough: preserve happy-cli output and exit code; avoid wrapper stack traces.
3981
+ const child = spawn(process.execPath, [join(rootDir, 'scripts', 'happy.mjs'), ...forwardedArgs], {
3982
+ cwd: rootDir,
3983
+ env: envForHappy,
3984
+ stdio: 'inherit',
3985
+ shell: false,
3986
+ });
3987
+
3988
+ const exitCode = await new Promise((resolvePromise) => {
3989
+ child.on('error', () => resolvePromise(1));
3990
+ child.on('exit', (code) => resolvePromise(code ?? 1));
3991
+ });
3992
+
3993
+ process.exit(exitCode);
3752
3994
  },
3753
3995
  });
3754
3996
  return;
@@ -3977,6 +4219,10 @@ async function main() {
3977
4219
  }
3978
4220
 
3979
4221
  main().catch((err) => {
3980
- console.error('[stack] failed:', err);
4222
+ const message = err instanceof Error ? err.message : String(err);
4223
+ console.error('[stack] failed:', message);
4224
+ if (process.env.DEBUG && err instanceof Error && err.stack) {
4225
+ console.error(err.stack);
4226
+ }
3981
4227
  process.exit(1);
3982
4228
  });