happy-stacks 0.6.11 → 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 (66) 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 +1297 -140
  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/import.mjs +19 -13
  35. package/scripts/monorepo.mjs +414 -23
  36. package/scripts/monorepo_port.test.mjs +136 -0
  37. package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
  38. package/scripts/provision/macos-lima-happy-vm.sh +34 -2
  39. package/scripts/review.mjs +347 -124
  40. package/scripts/review_pr.mjs +78 -2
  41. package/scripts/run.mjs +2 -1
  42. package/scripts/stack.mjs +265 -19
  43. package/scripts/stack_daemon_cmd.test.mjs +196 -0
  44. package/scripts/stack_happy_cmd.test.mjs +103 -0
  45. package/scripts/utils/cli/prereqs.mjs +12 -1
  46. package/scripts/utils/cli/wizard.mjs +19 -1
  47. package/scripts/utils/dev/daemon.mjs +3 -1
  48. package/scripts/utils/llm/assist.mjs +64 -31
  49. package/scripts/utils/llm/happys_runner.mjs +60 -0
  50. package/scripts/utils/llm/tools.mjs +3 -3
  51. package/scripts/utils/proc/pm.mjs +1 -1
  52. package/scripts/utils/review/detached_worktree.mjs +61 -0
  53. package/scripts/utils/review/detached_worktree.test.mjs +62 -0
  54. package/scripts/utils/review/findings.mjs +133 -20
  55. package/scripts/utils/review/findings.test.mjs +88 -1
  56. package/scripts/utils/review/runners/augment.mjs +71 -0
  57. package/scripts/utils/review/runners/augment.test.mjs +42 -0
  58. package/scripts/utils/review/runners/coderabbit.mjs +54 -10
  59. package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
  60. package/scripts/utils/review/sliced_runner.mjs +39 -0
  61. package/scripts/utils/review/sliced_runner.test.mjs +47 -0
  62. package/scripts/utils/review/tool_home_seed.mjs +99 -0
  63. package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
  64. package/scripts/utils/stack/cli_identities.mjs +29 -0
  65. package/scripts/utils/stack/startup.mjs +45 -7
  66. package/scripts/worktrees.mjs +8 -5
@@ -23,6 +23,18 @@ On your macOS host (this repo):
23
23
  This creates the VM if needed and configures **localhost port forwarding** for the port ranges used by our VM defaults.
24
24
  (This is important because the Expo web app uses WebCrypto and needs a secure context like `http://localhost`.)
25
25
 
26
+ It also sets a higher default VM memory size (to avoid Expo/Metro getting OOM-killed and exiting with `code=137`).
27
+ Override if needed:
28
+
29
+ ```bash
30
+ LIMA_MEMORY=12GiB ./scripts/provision/macos-lima-happy-vm.sh happy-test
31
+ ```
32
+
33
+ Port ranges note:
34
+ - `review-pr` runs in a **fully isolated sandbox** (separate happy-stacks home dir), so VM defaults written to
35
+ `~/.happy-stacks/env.local` inside the VM won’t be read automatically.
36
+ - Prefer passing `--vm-ports` (or explicit `--stack-port-start=...`) to `review-pr` so the sandbox uses the forwarded ranges.
37
+
26
38
  ### 2b) Manual setup (if you prefer)
27
39
 
28
40
  ```bash
@@ -30,6 +42,13 @@ limactl create --name happy-pr --tty=false template://ubuntu-24.04
30
42
  limactl start happy-pr
31
43
  ```
32
44
 
45
+ If you run `review-pr` (Expo web / Metro) inside the VM, **allocate enough memory** (recommend **8GiB+**).
46
+ Edit `~/.lima/happy-pr/lima.yaml` on the macOS host and set:
47
+
48
+ ```yaml
49
+ memory: "8GiB"
50
+ ```
51
+
33
52
  ### 2c) Host access (ports + browser URLs)
34
53
 
35
54
  When you want to open Happy/Expo URLs in your macOS browser, **use localhost port forwarding**.
@@ -116,6 +135,7 @@ Inside the VM:
116
135
  ```bash
117
136
  npx --yes happy-stacks@latest review-pr \
118
137
  --happy=https://github.com/slopus/happy/pull/<PR_NUMBER> \
138
+ --vm-ports \
119
139
  --no-mobile \
120
140
  --keep-sandbox \
121
141
  --verbose \
@@ -126,6 +146,8 @@ Notes:
126
146
  - `--no-mobile` keeps the validation focused (Expo mobile dev-client adds more host requirements).
127
147
  - You can also add `--keep-sandbox` if you want to inspect the sandbox contents after a failure.
128
148
  - For full reproducibility, pin the version: `npx --yes happy-stacks@0.3.0 review-pr ...`
149
+ - `--vm-ports` forces the stack/server and Expo dev-server (web) ports into the forwarded VM ranges
150
+ (pairs with the `portForwards` config in this doc).
129
151
 
130
152
  ### Optional: test **unreleased local changes**
131
153
 
@@ -170,7 +192,7 @@ On the macOS host:
170
192
  ```bash
171
193
  limactl stop happy-pr || true
172
194
  limactl delete happy-pr
173
- limactl create --name happy-pr --tty=false template://ubuntu-24.04
195
+ limactl create --name happy-pr --tty=false template:ubuntu-24.04
174
196
  limactl start happy-pr
175
197
  ```
176
198
 
package/docs/stacks.md CHANGED
@@ -312,9 +312,29 @@ happys stack auth <stack> login
312
312
  ```
313
313
 
314
314
  Notes:
315
+ - You can run **multiple daemons for the same stack** on **different accounts** using `--identity=<name>`.
316
+ - `default` (no flag): `~/.happy/stacks/<stack>/cli/...`
317
+ - `--identity=account-b`: `~/.happy/stacks/<stack>/cli-identities/account-b/...`
318
+ - To authenticate an identity without auto-opening a browser, use `--no-open` (it prints the URL so you can open it
319
+ in the browser profile/incognito window you want):
320
+
321
+ ```bash
322
+ happys stack auth <stack> login --identity=account-a --no-open
323
+ happys stack auth <stack> login --identity=account-b --no-open
324
+ ```
325
+
326
+ - To start/stop an identity’s daemon explicitly:
327
+
328
+ ```bash
329
+ happys stack daemon <stack> start --identity=account-a
330
+ happys stack daemon <stack> stop --identity=account-a
331
+ ```
332
+
315
333
  - For the **main** stack, use `<stack>=main` and the default `<port>=3005` (unless you changed it).
316
334
  - If you use Tailscale Serve, `HAPPY_WEBAPP_URL` should be your HTTPS URL (what you get from `happys tailscale url`).
317
- - Logs live under `~/.happy/stacks/<stack>/cli/logs/`.
335
+ - Logs live under:
336
+ - default identity: `~/.happy/stacks/<stack>/cli/logs/`
337
+ - named identities: `~/.happy/stacks/<stack>/cli-identities/<identity>/logs/`
318
338
 
319
339
  ## JSON mode
320
340
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "happy-stacks",
3
3
  "type": "module",
4
- "version": "0.6.11",
4
+ "version": "0.6.13",
5
5
  "packageManager": "pnpm@10.18.3",
6
6
  "bin": {
7
7
  "happys": "./bin/happys.mjs",
package/scripts/auth.mjs CHANGED
@@ -28,6 +28,7 @@ import { resolveHandyMasterSecretFromStack } from './utils/auth/handy_master_sec
28
28
  import { ensureDir, readTextIfExists } from './utils/fs/ops.mjs';
29
29
  import { stackExistsSync } from './utils/stack/stacks.mjs';
30
30
  import { checkDaemonState } from './daemon.mjs';
31
+ import { parseCliIdentityOrThrow, resolveCliHomeDirForIdentity } from './utils/stack/cli_identities.mjs';
31
32
  import {
32
33
  getCliHomeDirFromEnvOrDefault,
33
34
  getServerLightDataDirFromEnvOrDefault,
@@ -95,15 +96,35 @@ function resolveServerComponentForCurrentStack() {
95
96
 
96
97
  async function cmdDevKey({ argv, json }) {
97
98
  const { flags, kv } = parseArgs(argv);
99
+
100
+ // parseArgs currently only supports --k=v, but UX/docs commonly use: --k "value".
101
+ // Support both forms here (without changing global parsing semantics).
102
+ const argvKvValue = (name) => {
103
+ const n = String(name ?? '').trim();
104
+ if (!n) return '';
105
+ for (let i = 0; i < argv.length; i += 1) {
106
+ const a = String(argv[i] ?? '');
107
+ if (a === n) {
108
+ const next = String(argv[i + 1] ?? '');
109
+ if (next && !next.startsWith('--')) return next;
110
+ return '';
111
+ }
112
+ if (a.startsWith(`${n}=`)) {
113
+ return a.slice(`${n}=`.length);
114
+ }
115
+ }
116
+ return '';
117
+ };
118
+
98
119
  const wantPrint = flags.has('--print');
99
- const fmtRaw = (kv.get('--format') ?? '').trim();
120
+ const fmtRaw = (argvKvValue('--format') || (kv.get('--format') ?? '')).trim();
100
121
  // UX: the Happy UI restore screen expects the "backup" (XXXXX-...) format.
101
122
  //
102
123
  // IMPORTANT: the Happy restore screen treats any key containing '-' as "backup format",
103
124
  // so printing a base64url key (which may contain '-') is *not reliably pasteable*.
104
125
  // Default to backup always unless explicitly overridden.
105
126
  const fmt = fmtRaw || 'backup'; // base64url | backup
106
- const set = (kv.get('--set') ?? '').trim();
127
+ const set = (argvKvValue('--set') || (kv.get('--set') ?? '')).trim();
107
128
  const clear = flags.has('--clear');
108
129
 
109
130
  if (set) {
@@ -737,6 +758,9 @@ async function cmdCopyFrom({ argv, json }) {
737
758
  async function cmdStatus({ json }) {
738
759
  const rootDir = getRootDir(import.meta.url);
739
760
  const stackName = getStackName();
761
+ const argv = process.argv.slice(2);
762
+ const { kv } = parseArgs(argv);
763
+ const identity = parseCliIdentityOrThrow((kv.get('--identity') ?? '').trim());
740
764
 
741
765
  const { port, url: internalServerUrl } = getInternalServerUrlCompat();
742
766
  const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port, stackName });
@@ -748,7 +772,7 @@ async function cmdStatus({ json }) {
748
772
  stackName,
749
773
  });
750
774
 
751
- const cliHomeDir = resolveCliHomeDir();
775
+ const cliHomeDir = resolveCliHomeDirForIdentity({ cliHomeDir: resolveCliHomeDir(), identity });
752
776
  const accessKeyPath = join(cliHomeDir, 'access.key');
753
777
  const settingsPath = join(cliHomeDir, 'settings.json');
754
778
 
@@ -773,6 +797,7 @@ async function cmdStatus({ json }) {
773
797
  internalServerUrl,
774
798
  publicServerUrl,
775
799
  cliHomeDir,
800
+ cliIdentity: identity,
776
801
  auth,
777
802
  daemon,
778
803
  serverHealth: health,
@@ -844,11 +869,13 @@ async function cmdLogin({ argv, json }) {
844
869
  const webappUrl = await preferStackLocalhostUrl(webappUrlRaw, { stackName });
845
870
  const webappUrlSource = expoWebappUrl ? 'expo' : envWebappUrl ? 'stack env override' : 'server';
846
871
 
847
- const cliHomeDir = resolveCliHomeDir();
872
+ const identity = parseCliIdentityOrThrow((kv.get('--identity') ?? '').trim());
873
+ const cliHomeDir = resolveCliHomeDirForIdentity({ cliHomeDir: resolveCliHomeDir(), identity });
848
874
  const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
849
875
 
850
876
  const force = !argv.includes('--no-force');
851
877
  const wantPrint = argv.includes('--print');
878
+ const noOpen = flags.has('--no-open') || flags.has('--no-browser') || flags.has('--no-browser-open');
852
879
  const contextRaw =
853
880
  (kv.get('--context') ?? process.env.HAPPY_STACKS_AUTH_LOGIN_CONTEXT ?? process.env.HAPPY_LOCAL_AUTH_LOGIN_CONTEXT ?? '')
854
881
  .toString()
@@ -859,18 +886,29 @@ async function cmdLogin({ argv, json }) {
859
886
  if (force || argv.includes('--force')) {
860
887
  nodeArgs.push('--force');
861
888
  }
889
+ if (noOpen) {
890
+ nodeArgs.push('--no-open');
891
+ }
862
892
 
863
893
  const env = {
864
894
  ...process.env,
865
895
  HAPPY_HOME_DIR: cliHomeDir,
866
896
  HAPPY_SERVER_URL: internalServerUrl,
867
897
  HAPPY_WEBAPP_URL: webappUrl,
898
+ ...(noOpen ? { HAPPY_NO_BROWSER_OPEN: '1' } : {}),
868
899
  };
869
900
 
870
901
  if (wantPrint) {
871
- const cmd = `HAPPY_HOME_DIR="${cliHomeDir}" HAPPY_SERVER_URL="${internalServerUrl}" HAPPY_WEBAPP_URL="${webappUrl}" node "${cliBin}" auth login${nodeArgs.includes('--force') ? ' --force' : ''}`;
902
+ const cmd =
903
+ `HAPPY_HOME_DIR="${cliHomeDir}" ` +
904
+ `HAPPY_SERVER_URL="${internalServerUrl}" ` +
905
+ `HAPPY_WEBAPP_URL="${webappUrl}" ` +
906
+ (noOpen ? `HAPPY_NO_BROWSER_OPEN="1" ` : '') +
907
+ `node "${cliBin}" auth login` +
908
+ (nodeArgs.includes('--force') ? ' --force' : '') +
909
+ (noOpen ? ' --no-open' : '');
872
910
  if (json) {
873
- printResult({ json, data: { ok: true, stackName, cmd } });
911
+ printResult({ json, data: { ok: true, stackName, cliIdentity: identity, cmd } });
874
912
  } else {
875
913
  console.log(cmd);
876
914
  }
@@ -959,7 +997,7 @@ async function main() {
959
997
  sectionTitle('Usage (global)'),
960
998
  bullets([
961
999
  `${dim('status:')} ${cmdFmt('happys auth status')} ${dim('[--json]')}`,
962
- `${dim('login:')} ${cmdFmt('happys auth login')} ${dim('[--force] [--print] [--json]')}`,
1000
+ `${dim('login:')} ${cmdFmt('happys auth login')} ${dim('[--identity=<name>] [--no-open] [--force] [--print] [--json]')}`,
963
1001
  `${dim('seed:')} ${cmdFmt('happys auth copy-from <sourceStack|legacy> --all')} ${dim('[--except=main,dev-auth] [--force] [--with-infra] [--link] [--json]')}`,
964
1002
  `${dim('dev key:')} ${cmdFmt('happys auth dev-key')} ${dim('[--print] [--format=base64url|backup] [--set=<secret>] [--clear] [--json]')}`,
965
1003
  ]),
@@ -967,7 +1005,7 @@ async function main() {
967
1005
  sectionTitle('Usage (stack-scoped)'),
968
1006
  bullets([
969
1007
  `${dim('status:')} ${cmdFmt('happys stack auth <name> status')} ${dim('[--json]')}`,
970
- `${dim('login:')} ${cmdFmt('happys stack auth <name> login')} ${dim('[--force] [--print] [--json]')}`,
1008
+ `${dim('login:')} ${cmdFmt('happys stack auth <name> login')} ${dim('[--identity=<name>] [--no-open] [--force] [--print] [--json]')}`,
971
1009
  `${dim('seed:')} ${cmdFmt('happys stack auth <name> copy-from <sourceStack|legacy>')} ${dim('[--force] [--with-infra] [--link] [--json]')}`,
972
1010
  ]),
973
1011
  '',
@@ -193,28 +193,37 @@ function resolveHappyCliDistEntrypoint(cliBin) {
193
193
  async function ensureHappyCliDistExists({ cliBin }) {
194
194
  const distEntrypoint = resolveHappyCliDistEntrypoint(cliBin);
195
195
  if (!distEntrypoint) return { ok: false, distEntrypoint: null, built: false, reason: 'unknown_cli_bin' };
196
- if (existsSync(distEntrypoint)) return { ok: true, distEntrypoint, built: false, reason: 'exists' };
196
+ const cliDir = join(dirname(cliBin), '..');
197
197
 
198
198
  // Try to recover automatically: missing dist is a common first-run worktree issue.
199
199
  // We build in-place using the cliDir that owns this cliBin (../ from bin/).
200
- const cliDir = join(dirname(cliBin), '..');
201
200
  const buildCli =
202
201
  (process.env.HAPPY_STACKS_CLI_BUILD ?? process.env.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
203
202
  if (!buildCli) {
204
203
  return { ok: false, distEntrypoint, built: false, reason: 'build_disabled' };
205
204
  }
206
205
 
206
+ let buildRes = null;
207
207
  try {
208
- // eslint-disable-next-line no-console
209
- console.warn(`[local] happy-cli build output missing; rebuilding (${cliDir})...`);
210
- await ensureCliBuilt(cliDir, { buildCli: true });
208
+ // In auto mode, ensureCliBuilt() is a fast no-op when nothing changed.
209
+ buildRes = await ensureCliBuilt(cliDir, { buildCli: true });
210
+ if (buildRes?.built) {
211
+ // eslint-disable-next-line no-console
212
+ console.warn(`[local] happy-cli: rebuilt (${cliDir})`);
213
+ }
211
214
  } catch (e) {
212
215
  return { ok: false, distEntrypoint, built: false, reason: String(e?.message ?? e) };
213
216
  }
214
217
 
215
- return existsSync(distEntrypoint)
216
- ? { ok: true, distEntrypoint, built: true, reason: 'rebuilt' }
217
- : { ok: false, distEntrypoint, built: true, reason: 'rebuilt_but_missing' };
218
+ if (existsSync(distEntrypoint)) {
219
+ return {
220
+ ok: true,
221
+ distEntrypoint,
222
+ built: Boolean(buildRes?.built),
223
+ reason: buildRes?.built ? (buildRes.reason ?? 'rebuilt') : 'exists',
224
+ };
225
+ }
226
+ return { ok: false, distEntrypoint, built: Boolean(buildRes?.built), reason: buildRes?.built ? 'rebuilt_but_missing' : 'missing' };
218
227
  }
219
228
 
220
229
  function excerptIndicatesMissingAuth(excerpt) {
@@ -243,14 +252,17 @@ function allowDaemonWaitForAuthWithoutTty() {
243
252
  return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y';
244
253
  }
245
254
 
246
- function authLoginHint() {
247
- const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
248
- return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
255
+ function authLoginHint({ stackName, cliIdentity }) {
256
+ const id = (cliIdentity ?? '').toString().trim();
257
+ const suffix = id && id !== 'default' ? ` --identity=${id} --no-open` : '';
258
+ return stackName === 'main' ? `happys auth login${suffix}` : `happys stack auth ${stackName} login${suffix}`;
249
259
  }
250
260
 
251
- function authCopyFromSeedHint() {
252
- const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
261
+ function authCopyFromSeedHint({ stackName, cliIdentity }) {
253
262
  if (stackName === 'main') return null;
263
+ // For multi-identity daemons, copying credentials defeats the purpose (multiple accounts).
264
+ const id = (cliIdentity ?? '').toString().trim();
265
+ if (id && id !== 'default') return null;
254
266
  const seed = resolveAuthSeedFromEnv(process.env);
255
267
  return `happys stack auth ${stackName} copy-from ${seed}`;
256
268
  }
@@ -456,11 +468,16 @@ export async function startLocalDaemonWithAuth({
456
468
  forceRestart = false,
457
469
  env = process.env,
458
470
  stackName = null,
471
+ cliIdentity = 'default',
459
472
  }) {
460
473
  const resolvedStackName =
461
474
  (stackName ?? '').toString().trim() ||
462
475
  (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
463
476
  'main';
477
+ const resolvedCliIdentity =
478
+ (cliIdentity ?? '').toString().trim() ||
479
+ (env.HAPPY_STACKS_CLI_IDENTITY ?? env.HAPPY_LOCAL_CLI_IDENTITY ?? '').toString().trim() ||
480
+ 'default';
464
481
  const baseEnv = { ...env };
465
482
  const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
466
483
 
@@ -497,7 +514,7 @@ export async function startLocalDaemonWithAuth({
497
514
  return;
498
515
  }
499
516
 
500
- if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
517
+ if (!forceRestart && existing.status === 'running') {
501
518
  const pid = existing.pid;
502
519
  const matches = await daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl });
503
520
  if (matches === true) {
@@ -518,6 +535,12 @@ export async function startLocalDaemonWithAuth({
518
535
  return;
519
536
  }
520
537
  }
538
+ if (!forceRestart && existing.status === 'starting') {
539
+ // A lock file without a stable daemon.state.json usually means the daemon never finished starting
540
+ // (common when auth is required but daemon start is non-interactive). Attempt a safe restart.
541
+ // eslint-disable-next-line no-console
542
+ console.warn(`[local] daemon appears stuck starting for stack home (pid=${existing.pid}); restarting...`);
543
+ }
521
544
 
522
545
  // Stop any existing daemon for THIS stack home dir.
523
546
  try {
@@ -569,7 +592,7 @@ export async function startLocalDaemonWithAuth({
569
592
  // daemon status separately.
570
593
  await delay(500);
571
594
  const stateAfter = checkDaemonState(cliHomeDir);
572
- if (stateAfter.status === 'running' || stateAfter.status === 'starting') {
595
+ if (stateAfter.status === 'running') {
573
596
  return { ok: true, exitCode, excerpt: null, logPath: null };
574
597
  }
575
598
 
@@ -590,11 +613,11 @@ export async function startLocalDaemonWithAuth({
590
613
 
591
614
  if (excerptIndicatesMissingAuth(first.excerpt)) {
592
615
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || allowDaemonWaitForAuthWithoutTty();
593
- const copyHint = authCopyFromSeedHint();
616
+ const copyHint = authCopyFromSeedHint({ stackName: resolvedStackName, cliIdentity: resolvedCliIdentity });
594
617
  const hint =
595
618
  `[local] daemon is not authenticated yet (expected on first run).\n` +
596
619
  `[local] In another terminal, run:\n` +
597
- `${authLoginHint()}\n` +
620
+ `${authLoginHint({ stackName: resolvedStackName, cliIdentity: resolvedCliIdentity })}\n` +
598
621
  (copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '');
599
622
  if (!isInteractive) {
600
623
  throw new Error(`${hint}[local] Non-interactive mode: refusing to wait for credentials.`);
@@ -629,12 +652,12 @@ export async function startLocalDaemonWithAuth({
629
652
  try {
630
653
  await maybeAutoReseedInvalidAuth({ stackName });
631
654
  } catch (e) {
632
- const copyHint = authCopyFromSeedHint();
655
+ const copyHint = authCopyFromSeedHint({ stackName: resolvedStackName, cliIdentity: resolvedCliIdentity });
633
656
  console.error(
634
657
  `[local] daemon credentials were rejected by the server (401).\n` +
635
658
  `[local] Fix:\n` +
636
659
  (copyHint ? `- ${copyHint}\n` : '') +
637
- `- ${authLoginHint()}`
660
+ `- ${authLoginHint({ stackName: resolvedStackName, cliIdentity: resolvedCliIdentity })}`
638
661
  );
639
662
  throw e;
640
663
  }
@@ -648,13 +671,13 @@ export async function startLocalDaemonWithAuth({
648
671
  throw new Error('Failed to start daemon (after auth re-seed)');
649
672
  }
650
673
  } else {
651
- const copyHint = authCopyFromSeedHint();
674
+ const copyHint = authCopyFromSeedHint({ stackName: resolvedStackName, cliIdentity: resolvedCliIdentity });
652
675
  console.error(
653
676
  `[local] daemon failed to start (server returned an error).\n` +
654
677
  `[local] Try:\n` +
655
678
  `- happys doctor\n` +
656
679
  (copyHint ? `- ${copyHint}\n` : '') +
657
- `- ${authLoginHint()}`
680
+ `- ${authLoginHint({ stackName: resolvedStackName, cliIdentity: resolvedCliIdentity })}`
658
681
  );
659
682
  throw new Error('Failed to start daemon');
660
683
  }
@@ -63,7 +63,7 @@ async function fetchHealth(url) {
63
63
 
64
64
  async function main() {
65
65
  const argv = process.argv.slice(2);
66
- const { flags, kv } = parseArgs(argv);
66
+ const { flags, kv: argsKv } = parseArgs(argv);
67
67
  const fix = flags.has('--fix');
68
68
  const json = wantsJson(argv, { flags });
69
69
 
@@ -117,7 +117,7 @@ async function main() {
117
117
  ? process.env.HAPPY_LOCAL_UI_BUILD_DIR.trim()
118
118
  : join(autostart.baseDir, 'ui');
119
119
 
120
- const serverComponentName = getServerComponentName({ kv });
120
+ const serverComponentName = getServerComponentName({ kv: argsKv });
121
121
  if (serverComponentName === 'both') {
122
122
  throw new Error(`[doctor] --server=both is not supported (pick one: happy-server-light or happy-server)`);
123
123
  }
@@ -0,0 +1,67 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ async function writeStubHappyCli({ dir }) {
22
+ await mkdir(join(dir, 'bin'), { recursive: true });
23
+ await mkdir(join(dir, 'dist'), { recursive: true });
24
+
25
+ // `scripts/doctor.mjs` runs: node <cliBin> daemon status
26
+ await writeFile(
27
+ join(dir, 'bin', 'happy.mjs'),
28
+ [
29
+ `if (process.argv.includes('daemon') && process.argv.includes('status')) {`,
30
+ ` console.log('Daemon is running');`,
31
+ ` process.exit(0);`,
32
+ `}`,
33
+ `console.log('ok');`,
34
+ ].join('\n'),
35
+ 'utf-8'
36
+ );
37
+
38
+ // Keep dist present to avoid incidental MODULE_NOT_FOUND in other helper paths.
39
+ await writeFile(join(dir, 'dist', 'index.mjs'), `export {};`, 'utf-8');
40
+ }
41
+
42
+ test('doctor does not crash in non-json mode (kv helper not shadowed)', async () => {
43
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
44
+ const rootDir = dirname(scriptsDir);
45
+
46
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-doctor-'));
47
+ const stubServer = join(tmp, 'happy-server-light');
48
+ const stubCli = join(tmp, 'happy-cli');
49
+ await mkdir(stubServer, { recursive: true });
50
+ await writeStubHappyCli({ dir: stubCli });
51
+
52
+ const env = {
53
+ ...process.env,
54
+ HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT: stubServer,
55
+ HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI: stubCli,
56
+ // Avoid UI build dir checks (keeps this test hermetic).
57
+ HAPPY_LOCAL_SERVE_UI: '0',
58
+ // Avoid any side effects in temp runs.
59
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
60
+ };
61
+
62
+ const res = await runNode([join(rootDir, 'scripts', 'doctor.mjs')], { cwd: rootDir, env });
63
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
64
+ assert.match(res.stdout, /happy-stacks doctor/i);
65
+ assert.match(res.stdout, /Details/);
66
+ });
67
+
package/scripts/happy.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import './utils/env/env.mjs';
2
- import { execFileSync } from 'node:child_process';
2
+ import { spawnSync } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { parseArgs } from './utils/cli/args.mjs';
@@ -43,7 +43,9 @@ async function main() {
43
43
  const cliDir = getComponentDir(rootDir, 'happy-cli');
44
44
  const entrypoint = join(cliDir, 'dist', 'index.mjs');
45
45
  if (!existsSync(entrypoint)) {
46
- throw new Error(`[happy] missing happy-cli build at: ${entrypoint}\nRun: happys bootstrap`);
46
+ console.error(`[happy] missing happy-cli build at: ${entrypoint}`);
47
+ console.error('Run: happys bootstrap');
48
+ process.exit(1);
47
49
  }
48
50
 
49
51
  const env = { ...process.env };
@@ -51,14 +53,25 @@ async function main() {
51
53
  env.HAPPY_SERVER_URL = env.HAPPY_SERVER_URL || internalServerUrl;
52
54
  env.HAPPY_WEBAPP_URL = env.HAPPY_WEBAPP_URL || publicServerUrl;
53
55
 
54
- execFileSync(process.execPath, ['--no-warnings', '--no-deprecation', entrypoint, ...argv], {
56
+ const res = spawnSync(process.execPath, ['--no-warnings', '--no-deprecation', entrypoint, ...argv], {
55
57
  stdio: 'inherit',
56
58
  env,
57
59
  });
60
+
61
+ if (res.error) {
62
+ const msg = res.error instanceof Error ? res.error.message : String(res.error);
63
+ console.error(`[happy] failed to run happy-cli: ${msg}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ process.exit(res.status ?? 1);
58
68
  }
59
69
 
60
70
  main().catch((err) => {
61
- console.error('[happy] failed:', err);
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ console.error('[happy] failed:', message);
73
+ if (process.env.DEBUG && err instanceof Error && err.stack) {
74
+ console.error(err.stack);
75
+ }
62
76
  process.exit(1);
63
77
  });
64
-
@@ -18,6 +18,7 @@ import { readEnvObjectFromFile } from './utils/env/read.mjs';
18
18
  import { clipboardAvailable, copyTextToClipboard } from './utils/ui/clipboard.mjs';
19
19
  import { detectInstalledLlmTools } from './utils/llm/tools.mjs';
20
20
  import { launchLlmAssistant } from './utils/llm/assist.mjs';
21
+ import { buildHappyStacksRunnerShellSnippet } from './utils/llm/happys_runner.mjs';
21
22
 
22
23
  function usage() {
23
24
  return [
@@ -505,30 +506,32 @@ function readPinnedComponentDirFromEnvObject(envObj, component) {
505
506
  }
506
507
 
507
508
  function buildLlmPromptForImport() {
509
+ const hs = buildHappyStacksRunnerShellSnippet();
508
510
  return [
509
511
  'You are an assistant helping the user migrate legacy Happy split repos into Happy Stacks.',
510
512
  '',
513
+ hs,
511
514
  'Goals:',
512
515
  '- Import legacy split repos (happy / happy-cli / happy-server) into a stack in Happy Stacks.',
513
516
  '- Optionally migrate commits into the slopus/happy monorepo layout (expo-app/cli/server).',
514
517
  '',
515
518
  'How to proceed:',
516
519
  '1) Run the guided import wizard:',
517
- ' - `happys import`',
520
+ ' - `hs import`',
518
521
  '',
519
522
  'Non-interactive (LLM-friendly) variant:',
520
523
  '- Inspect candidate repos/worktrees/branches (JSON):',
521
- ' - `happys import inspect --happy=<path|url> --happy-cli=<path|url> --happy-server=<path|url> --yes --json`',
524
+ ' - `hs import inspect --happy=<path|url> --happy-cli=<path|url> --happy-server=<path|url> --yes --json`',
522
525
  '- Apply pins to a stack (no prompts):',
523
- ' - `happys import apply --stack=<name> --server=happy-server-light --happy=<path|url> --happy-ref=<ref> --happy-cli=<path|url> --happy-cli-ref=<ref> --happy-server=<path|url> --happy-server-ref=<ref> --yes`',
526
+ ' - `hs import apply --stack=<name> --server=happy-server-light --happy=<path|url> --happy-ref=<ref> --happy-cli=<path|url> --happy-cli-ref=<ref> --happy-server=<path|url> --happy-server-ref=<ref> --yes`',
524
527
  '2) If you want to migrate an existing imported stack later:',
525
- ' - `happys import migrate --stack=<stack>`',
528
+ ' - `hs import migrate --stack=<stack>`',
526
529
  '',
527
530
  'Conflict handling (monorepo port):',
528
- '- Prefer guided mode: `happys monorepo port guide --target=<monorepo-root>`',
531
+ '- Prefer guided mode: `hs monorepo port guide --target=<monorepo-root>`',
529
532
  '- For machine-readable state, use:',
530
- ' - `happys monorepo port status --target=<monorepo-root> --json`',
531
- ' - `happys monorepo port continue --target=<monorepo-root>`',
533
+ ' - `hs monorepo port status --target=<monorepo-root> --json`',
534
+ ' - `hs monorepo port continue --target=<monorepo-root>`',
532
535
  '',
533
536
  'Important:',
534
537
  '- A “stack” is an isolated runtime (ports + data + env) under ~/.happy/stacks/<name>.',
@@ -538,9 +541,11 @@ function buildLlmPromptForImport() {
538
541
  }
539
542
 
540
543
  function buildLlmPromptForMigrate({ stackName }) {
544
+ const hs = buildHappyStacksRunnerShellSnippet();
541
545
  return [
542
546
  'You are an assistant helping the user migrate an existing Happy Stacks stack to the monorepo.',
543
547
  '',
548
+ hs,
544
549
  `Target stack: ${stackName || '<stack>'}`,
545
550
  '',
546
551
  'Goal:',
@@ -548,17 +553,17 @@ function buildLlmPromptForMigrate({ stackName }) {
548
553
  '- Create a new monorepo stack by default (keep the legacy stack intact).',
549
554
  '',
550
555
  'Command:',
551
- `- happys import migrate --stack=${stackName || '<stack>'}`,
556
+ `- hs import migrate --stack=${stackName || '<stack>'}`,
552
557
  '',
553
558
  'Conflict handling:',
554
- '- This uses `happys monorepo port guide` which pauses on conflicts.',
555
- '- To inspect machine-readably: `happys monorepo port status --target=<monorepo-root> --json`',
559
+ '- This uses `hs monorepo port guide` which pauses on conflicts.',
560
+ '- To inspect machine-readably: `hs monorepo port status --target=<monorepo-root> --json`',
556
561
  ].join('\n');
557
562
  }
558
563
 
559
564
  function buildMonorepoMigrationPrompt({ targetMonorepoRoot, branch, sources }) {
560
565
  const args = [
561
- `happys monorepo port --target=${targetMonorepoRoot} --branch=${branch} --3way`,
566
+ `hs monorepo port --target=${targetMonorepoRoot} --branch=${branch} --3way`,
562
567
  sources.happy ? `--from-happy=${sources.happy}` : '',
563
568
  sources['happy-cli'] ? `--from-happy-cli=${sources['happy-cli']}` : '',
564
569
  sources['happy-server'] ? `--from-happy-server=${sources['happy-server']}` : '',
@@ -569,6 +574,7 @@ function buildMonorepoMigrationPrompt({ targetMonorepoRoot, branch, sources }) {
569
574
  return [
570
575
  'You are an assistant helping the user migrate split-repo commits into the Happy monorepo layout.',
571
576
  '',
577
+ buildHappyStacksRunnerShellSnippet(),
572
578
  `Target monorepo worktree: ${targetMonorepoRoot}`,
573
579
  `Port branch: ${branch}`,
574
580
  '',
@@ -580,10 +586,10 @@ function buildMonorepoMigrationPrompt({ targetMonorepoRoot, branch, sources }) {
580
586
  args,
581
587
  '',
582
588
  'If it stops with conflicts:',
583
- `- Inspect: happys monorepo port status --target=${targetMonorepoRoot} --json`,
589
+ `- Inspect: hs monorepo port status --target=${targetMonorepoRoot} --json`,
584
590
  `- Resolve conflicted files (keep changes scoped to expo-app/, cli/, server/)`,
585
591
  `- Stage: git -C ${targetMonorepoRoot} add <files>`,
586
- `- Continue: happys monorepo port continue --target=${targetMonorepoRoot}`,
592
+ `- Continue: hs monorepo port continue --target=${targetMonorepoRoot}`,
587
593
  '',
588
594
  'Repeat status/resolve/continue until ok.',
589
595
  ].join('\n');