happy-stacks 0.4.0 → 0.5.0

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 (104) hide show
  1. package/README.md +64 -33
  2. package/bin/happys.mjs +44 -1
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +1 -2
  10. package/docs/monorepo-migration.md +286 -0
  11. package/docs/server-flavors.md +19 -3
  12. package/docs/stacks.md +35 -0
  13. package/package.json +1 -1
  14. package/scripts/auth.mjs +21 -3
  15. package/scripts/build.mjs +1 -1
  16. package/scripts/dev.mjs +20 -7
  17. package/scripts/doctor.mjs +0 -4
  18. package/scripts/edison.mjs +2 -2
  19. package/scripts/env.mjs +150 -0
  20. package/scripts/env_cmd.test.mjs +128 -0
  21. package/scripts/init.mjs +5 -2
  22. package/scripts/install.mjs +99 -57
  23. package/scripts/migrate.mjs +3 -12
  24. package/scripts/monorepo.mjs +1096 -0
  25. package/scripts/monorepo_port.test.mjs +1470 -0
  26. package/scripts/review.mjs +715 -24
  27. package/scripts/review_pr.mjs +5 -20
  28. package/scripts/run.mjs +21 -15
  29. package/scripts/setup.mjs +147 -25
  30. package/scripts/setup_pr.mjs +19 -28
  31. package/scripts/stack.mjs +493 -157
  32. package/scripts/stack_archive_cmd.test.mjs +91 -0
  33. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  34. package/scripts/stack_env_cmd.test.mjs +87 -0
  35. package/scripts/stack_happy_cmd.test.mjs +126 -0
  36. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  37. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  38. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  39. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  40. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  41. package/scripts/stack_wt_list.test.mjs +128 -0
  42. package/scripts/tui.mjs +88 -2
  43. package/scripts/utils/cli/cli_registry.mjs +20 -5
  44. package/scripts/utils/cli/cwd_scope.mjs +56 -2
  45. package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
  46. package/scripts/utils/cli/prereqs.mjs +8 -5
  47. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  48. package/scripts/utils/cli/wizard.mjs +17 -9
  49. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  50. package/scripts/utils/dev/daemon.mjs +14 -1
  51. package/scripts/utils/dev/expo_dev.mjs +188 -4
  52. package/scripts/utils/dev/server.mjs +21 -17
  53. package/scripts/utils/edison/git_roots.mjs +29 -0
  54. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  55. package/scripts/utils/env/env.mjs +7 -3
  56. package/scripts/utils/env/env_file.mjs +4 -2
  57. package/scripts/utils/env/env_file.test.mjs +44 -0
  58. package/scripts/utils/git/worktrees.mjs +63 -12
  59. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  60. package/scripts/utils/net/tcp_forward.mjs +162 -0
  61. package/scripts/utils/paths/paths.mjs +118 -3
  62. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  63. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  64. package/scripts/utils/proc/commands.mjs +2 -3
  65. package/scripts/utils/proc/pm.mjs +113 -16
  66. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  67. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  68. package/scripts/utils/proc/proc.mjs +68 -10
  69. package/scripts/utils/proc/proc.test.mjs +77 -0
  70. package/scripts/utils/review/chunks.mjs +55 -0
  71. package/scripts/utils/review/chunks.test.mjs +51 -0
  72. package/scripts/utils/review/findings.mjs +165 -0
  73. package/scripts/utils/review/findings.test.mjs +85 -0
  74. package/scripts/utils/review/head_slice.mjs +153 -0
  75. package/scripts/utils/review/head_slice.test.mjs +91 -0
  76. package/scripts/utils/review/instructions/deep.md +20 -0
  77. package/scripts/utils/review/runners/coderabbit.mjs +56 -14
  78. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  79. package/scripts/utils/review/runners/codex.mjs +32 -22
  80. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  81. package/scripts/utils/review/slices.mjs +140 -0
  82. package/scripts/utils/review/slices.test.mjs +32 -0
  83. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  84. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  85. package/scripts/utils/server/prisma_import.mjs +37 -0
  86. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  87. package/scripts/utils/server/ui_env.mjs +14 -0
  88. package/scripts/utils/server/ui_env.test.mjs +46 -0
  89. package/scripts/utils/server/validate.mjs +53 -16
  90. package/scripts/utils/server/validate.test.mjs +89 -0
  91. package/scripts/utils/stack/editor_workspace.mjs +4 -4
  92. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  93. package/scripts/utils/stack/startup.mjs +113 -13
  94. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  95. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  96. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  97. package/scripts/utils/tailscale/ip.mjs +116 -0
  98. package/scripts/utils/ui/ansi.mjs +39 -0
  99. package/scripts/where.mjs +2 -2
  100. package/scripts/worktrees.mjs +627 -137
  101. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  102. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  103. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  104. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
@@ -3,17 +3,51 @@ import { ensureDepsInstalled, pmExecBin } from '../proc/pm.mjs';
3
3
  import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
+ import { mkdir } from 'node:fs/promises';
7
+ import { resolvePrismaClientImportForServerComponent, resolveServerLightPrismaMigrateDeployArgs, resolveServerLightPrismaSchemaArgs } from '../server/flavor_scripts.mjs';
6
8
 
7
9
  function looksLikeMissingTableError(msg) {
8
10
  const s = String(msg ?? '').toLowerCase();
9
11
  return s.includes('does not exist') || s.includes('no such table');
10
12
  }
11
13
 
12
- async function probeAccountCount({ serverDir, env }) {
14
+ function looksLikeAlreadyExistsError(msg) {
15
+ const s = String(msg ?? '').toLowerCase();
16
+ return s.includes('already exists') || s.includes('duplicate') || s.includes('constraint failed');
17
+ }
18
+
19
+ function looksLikeMissingGeneratedSqliteClientError(err) {
20
+ const code = err && typeof err === 'object' ? err.code : '';
21
+ if (code !== 'ERR_MODULE_NOT_FOUND') return false;
22
+ const msg = err instanceof Error ? err.message : String(err ?? '');
23
+ return msg.includes('/generated/sqlite-client/') || msg.includes('\\generated\\sqlite-client\\');
24
+ }
25
+
26
+ async function findSqliteBaselineMigrationDir({ serverDir }) {
27
+ try {
28
+ // Unified monorepo server-light migrations live under prisma/sqlite/migrations.
29
+ // For legacy schema.sqlite.prisma setups, migrations use the default prisma/migrations folder.
30
+ const migrationsDir = existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
31
+ ? join(serverDir, 'prisma', 'sqlite', 'migrations')
32
+ : join(serverDir, 'prisma', 'migrations');
33
+ const { readdir } = await import('node:fs/promises');
34
+ const entries = await readdir(migrationsDir, { withFileTypes: true });
35
+ const dirs = entries
36
+ .filter((e) => e.isDirectory())
37
+ .map((e) => e.name)
38
+ .sort();
39
+ return dirs[0] || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ async function probeAccountCount({ serverComponentName, serverDir, env }) {
46
+ const clientImport = resolvePrismaClientImportForServerComponent({ serverComponentName, serverDir });
13
47
  const probe = `
14
48
  let db;
15
49
  try {
16
- const { PrismaClient } = await import('@prisma/client');
50
+ const { PrismaClient } = await import(${JSON.stringify(clientImport)});
17
51
  db = new PrismaClient();
18
52
  const accountCount = await db.account.count();
19
53
  console.log(JSON.stringify({ accountCount }));
@@ -90,27 +124,93 @@ export function resolveAuthSeedFromEnv(env) {
90
124
  }
91
125
 
92
126
  export async function ensureServerLightSchemaReady({ serverDir, env }) {
93
- await ensureDepsInstalled(serverDir, 'happy-server-light');
127
+ await ensureDepsInstalled(serverDir, 'happy-server-light', { env });
128
+
129
+ const dataDir = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').toString().trim();
130
+ const filesDir = (env?.HAPPY_SERVER_LIGHT_FILES_DIR ?? '').toString().trim() || (dataDir ? join(dataDir, 'files') : '');
131
+ if (dataDir) {
132
+ try {
133
+ await mkdir(dataDir, { recursive: true });
134
+ } catch {
135
+ // best-effort
136
+ }
137
+ }
138
+ if (filesDir) {
139
+ try {
140
+ await mkdir(filesDir, { recursive: true });
141
+ } catch {
142
+ // best-effort
143
+ }
144
+ }
145
+
146
+ const probe = async () => await probeAccountCount({ serverComponentName: 'happy-server-light', serverDir, env });
147
+ const schemaArgs = resolveServerLightPrismaSchemaArgs({ serverDir });
94
148
 
149
+ const isUnified = schemaArgs.length > 0;
150
+
151
+ // Unified server-light (monorepo): ensure deterministic migrations are applied (idempotent).
152
+ // Legacy server-light (single schema.prisma with db push): do NOT run `prisma migrate deploy`,
153
+ // because it commonly fails with P3005 when the DB was created by `prisma db push` and no migrations exist.
154
+ if (isUnified) {
155
+ try {
156
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
157
+ } catch (e) {
158
+ const msg = e instanceof Error ? e.message : String(e);
159
+ // If the SQLite DB was created before migrations existed (historical db push era),
160
+ // `migrate deploy` can fail because tables already exist. Best-effort: baseline-resolve
161
+ // the first migration, then retry deploy.
162
+ if (looksLikeAlreadyExistsError(msg)) {
163
+ const baseline = await findSqliteBaselineMigrationDir({ serverDir });
164
+ if (baseline) {
165
+ await pmExecBin({
166
+ dir: serverDir,
167
+ bin: 'prisma',
168
+ args: ['migrate', 'resolve', ...schemaArgs, '--applied', baseline],
169
+ env,
170
+ }).catch(() => {});
171
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
172
+ } else {
173
+ throw e;
174
+ }
175
+ } else {
176
+ throw e;
177
+ }
178
+ }
179
+ }
180
+
181
+ // 2) Probe account count (used for auth seeding heuristics).
95
182
  try {
96
- const accountCount = await probeAccountCount({ serverDir, env });
97
- return { ok: true, pushed: false, accountCount };
183
+ const accountCount = await probe();
184
+ return { ok: true, migrated: isUnified, accountCount };
98
185
  } catch (e) {
186
+ if (looksLikeMissingGeneratedSqliteClientError(e)) {
187
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['generate', ...schemaArgs], env });
188
+ const accountCount = await probe();
189
+ return { ok: true, migrated: isUnified, accountCount };
190
+ }
99
191
  const msg = e instanceof Error ? e.message : String(e);
100
- if (!looksLikeMissingTableError(msg)) {
101
- throw e;
192
+ if (looksLikeMissingTableError(msg)) {
193
+ if (isUnified) {
194
+ // Tables still missing after migrate deploy; fail closed with a clear error.
195
+ throw new Error(`[server-light] sqlite schema not ready after prisma migrate deploy (missing tables).`);
196
+ }
197
+ // Legacy server-light: schema is typically applied via `prisma db push` in the component's dev/start scripts.
198
+ // Best-effort: don't fail the whole stack startup just because we can't probe here.
199
+ return { ok: true, migrated: false, accountCount: 0 };
200
+ }
201
+ if (!isUnified) {
202
+ // Legacy server-light: probing is best-effort (don't make stack dev fail closed here).
203
+ return { ok: true, migrated: false, accountCount: 0 };
102
204
  }
103
- await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['db', 'push'], env });
104
- const accountCount = await probeAccountCount({ serverDir, env });
105
- return { ok: true, pushed: true, accountCount };
205
+ throw e;
106
206
  }
107
207
  }
108
208
 
109
209
  export async function ensureHappyServerSchemaReady({ serverDir, env }) {
110
- await ensureDepsInstalled(serverDir, 'happy-server');
210
+ await ensureDepsInstalled(serverDir, 'happy-server', { env });
111
211
 
112
212
  try {
113
- const accountCount = await probeAccountCount({ serverDir, env });
213
+ const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
114
214
  return { ok: true, migrated: false, accountCount };
115
215
  } catch (e) {
116
216
  const msg = e instanceof Error ? e.message : String(e);
@@ -119,7 +219,7 @@ export async function ensureHappyServerSchemaReady({ serverDir, env }) {
119
219
  }
120
220
  // If tables are missing, try migrations (safe for postgres). Then re-probe.
121
221
  await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
122
- const accountCount = await probeAccountCount({ serverDir, env });
222
+ const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
123
223
  return { ok: true, migrated: true, accountCount };
124
224
  }
125
225
  }
@@ -0,0 +1,64 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+
8
+ import { ensureServerLightSchemaReady } from './startup.mjs';
9
+
10
+ async function writeJson(path, obj) {
11
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
12
+ }
13
+
14
+ test('ensureServerLightSchemaReady creates stack sqlite data dirs before probing', async (t) => {
15
+ const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-dirs-'));
16
+ t.after(async () => {
17
+ await rm(root, { recursive: true, force: true });
18
+ });
19
+
20
+ const serverDir = join(root, 'server');
21
+ await mkdir(serverDir, { recursive: true });
22
+ await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
23
+ await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
24
+ await mkdir(join(serverDir, 'node_modules'), { recursive: true });
25
+ await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
26
+
27
+ await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
28
+ await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
29
+
30
+ // Provide the generated client so we don't need to run prisma generate in this test.
31
+ await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
32
+ await writeFile(
33
+ join(serverDir, 'generated', 'sqlite-client', 'index.js'),
34
+ ['export class PrismaClient {', ' constructor() { this.account = { count: async () => 0 }; }', ' async $disconnect() {}', '}'].join('\n') +
35
+ '\n',
36
+ 'utf-8'
37
+ );
38
+
39
+ // Minimal stub `yarn` so commandExists('yarn') succeeds.
40
+ const binDir = join(root, 'bin');
41
+ await mkdir(binDir, { recursive: true });
42
+ const yarnPath = join(binDir, 'yarn');
43
+ await writeFile(yarnPath, ['#!/usr/bin/env node', "console.log('1.22.22');"].join('\n') + '\n', 'utf-8');
44
+ await chmod(yarnPath, 0o755);
45
+
46
+ const dataDir = join(root, 'data');
47
+ const filesDir = join(dataDir, 'files');
48
+ const env = {
49
+ ...process.env,
50
+ PATH: `${binDir}:${process.env.PATH ?? ''}`,
51
+ HAPPY_SERVER_LIGHT_DATA_DIR: dataDir,
52
+ HAPPY_SERVER_LIGHT_FILES_DIR: filesDir,
53
+ DATABASE_URL: `file:${join(dataDir, 'happy-server-light.sqlite')}`,
54
+ };
55
+
56
+ assert.equal(existsSync(dataDir), false);
57
+ assert.equal(existsSync(filesDir), false);
58
+
59
+ await ensureServerLightSchemaReady({ serverDir, env });
60
+
61
+ assert.equal(existsSync(dataDir), true);
62
+ assert.equal(existsSync(filesDir), true);
63
+ });
64
+
@@ -0,0 +1,70 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile, chmod } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { ensureServerLightSchemaReady } from './startup.mjs';
8
+
9
+ async function writeJson(path, obj) {
10
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
11
+ }
12
+
13
+ test('ensureServerLightSchemaReady runs prisma generate when unified sqlite client is missing', async (t) => {
14
+ const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-generate-'));
15
+ t.after(async () => {
16
+ await rm(root, { recursive: true, force: true });
17
+ });
18
+
19
+ const serverDir = join(root, 'server');
20
+ await mkdir(serverDir, { recursive: true });
21
+ await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
22
+ await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
23
+
24
+ // Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
25
+ await mkdir(join(serverDir, 'node_modules'), { recursive: true });
26
+ await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
27
+
28
+ // Unified light detection + expected schema path.
29
+ await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
30
+ await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
31
+
32
+ // generated/sqlite-client exists, but the entrypoint is missing (this triggers ERR_MODULE_NOT_FOUND).
33
+ await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
34
+
35
+ // Provide a stub `yarn` in PATH so pmExecBin("prisma", ...) succeeds without real dependencies.
36
+ const binDir = join(root, 'bin');
37
+ await mkdir(binDir, { recursive: true });
38
+ const yarnPath = join(binDir, 'yarn');
39
+ await writeFile(
40
+ yarnPath,
41
+ [
42
+ '#!/usr/bin/env node',
43
+ "const { writeFileSync } = require('node:fs');",
44
+ "const { join } = require('node:path');",
45
+ 'const cwd = process.cwd();',
46
+ 'const out = join(cwd, "generated", "sqlite-client", "index.js");',
47
+ 'const text = [',
48
+ " 'export class PrismaClient {',",
49
+ " ' constructor() { this.account = { count: async () => 0 }; }',",
50
+ " ' async $disconnect() {}',",
51
+ " '}',",
52
+ "].join('\\n') + '\\n';",
53
+ 'writeFileSync(out, text, "utf-8");',
54
+ 'process.exit(0);',
55
+ ].join('\n') + '\n',
56
+ 'utf-8'
57
+ );
58
+ await chmod(yarnPath, 0o755);
59
+
60
+ const oldPath = process.env.PATH;
61
+ try {
62
+ process.env.PATH = `${binDir}:${oldPath ?? ''}`;
63
+ const res = await ensureServerLightSchemaReady({ serverDir, env: process.env });
64
+ assert.equal(res.ok, true);
65
+ assert.equal(res.migrated, true);
66
+ assert.equal(res.accountCount, 0);
67
+ } finally {
68
+ process.env.PATH = oldPath;
69
+ }
70
+ });
@@ -0,0 +1,88 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+
8
+ import { ensureServerLightSchemaReady } from './startup.mjs';
9
+
10
+ async function writeJson(path, obj) {
11
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
12
+ }
13
+
14
+ test('ensureServerLightSchemaReady does not run prisma migrate deploy for legacy happy-server-light checkouts', async (t) => {
15
+ const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-legacy-'));
16
+ t.after(async () => {
17
+ await rm(root, { recursive: true, force: true });
18
+ });
19
+
20
+ const serverDir = join(root, 'server');
21
+ await mkdir(serverDir, { recursive: true });
22
+ await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0', type: 'module' });
23
+ await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
24
+
25
+ // Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
26
+ await mkdir(join(serverDir, 'node_modules'), { recursive: true });
27
+ await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
28
+
29
+ // Legacy checkout: no prisma/sqlite/schema.prisma and no prisma/schema.sqlite.prisma.
30
+ // Provide a minimal node_modules @prisma/client so probeAccountCount can succeed.
31
+ await mkdir(join(serverDir, 'node_modules', '@prisma', 'client'), { recursive: true });
32
+ await writeJson(join(serverDir, 'node_modules', '@prisma', 'client', 'package.json'), {
33
+ name: '@prisma/client',
34
+ type: 'module',
35
+ main: './index.js',
36
+ });
37
+ await writeFile(
38
+ join(serverDir, 'node_modules', '@prisma', 'client', 'index.js'),
39
+ [
40
+ 'export class PrismaClient {',
41
+ ' constructor() { this.account = { count: async () => 0 }; }',
42
+ ' async $disconnect() {}',
43
+ '}',
44
+ ].join('\n') + '\n',
45
+ 'utf-8'
46
+ );
47
+
48
+ const marker = join(root, 'called-prisma.txt');
49
+
50
+ // Provide a stub `yarn` so ensureYarnReady + pmExecBin are controllable.
51
+ const binDir = join(root, 'bin');
52
+ await mkdir(binDir, { recursive: true });
53
+ const yarnPath = join(binDir, 'yarn');
54
+ await writeFile(
55
+ yarnPath,
56
+ [
57
+ '#!/usr/bin/env node',
58
+ "const fs = require('node:fs');",
59
+ "const path = require('node:path');",
60
+ "const args = process.argv.slice(2);",
61
+ // ensureYarnReady calls: yarn --version
62
+ "if (args.includes('--version')) { console.log('1.22.22'); process.exit(0); }",
63
+ // pmExecBin calls: yarn run prisma ...
64
+ "if (args[0] === 'run' && args[1] === 'prisma') {",
65
+ ` fs.writeFileSync(${JSON.stringify(marker)}, args.join(' ') + '\\n', 'utf-8');`,
66
+ ' process.exit(0);',
67
+ '}',
68
+ "console.log('ok');",
69
+ 'process.exit(0);',
70
+ ].join('\n') + '\n',
71
+ 'utf-8'
72
+ );
73
+ await chmod(yarnPath, 0o755);
74
+
75
+ const env = {
76
+ ...process.env,
77
+ PATH: `${binDir}:${process.env.PATH ?? ''}`,
78
+ DATABASE_URL: `file:${join(root, 'happy-server-light.sqlite')}`,
79
+ };
80
+
81
+ const res = await ensureServerLightSchemaReady({ serverDir, env });
82
+ assert.equal(res.ok, true);
83
+ assert.equal(res.migrated, false);
84
+ assert.equal(res.accountCount, 0);
85
+
86
+ assert.equal(existsSync(marker), false, `expected no prisma migrate deploy call, but saw: ${marker}`);
87
+ });
88
+
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tailscale IP detection utilities.
3
+ *
4
+ * Provides functions to detect the local Tailscale IPv4 address for port forwarding.
5
+ */
6
+
7
+ import { runCaptureResult } from '../proc/proc.mjs';
8
+ import { resolveCommandPath } from '../proc/commands.mjs';
9
+ import { access, constants } from 'node:fs/promises';
10
+
11
+ const TAILSCALE_TIMEOUT_MS = 3000;
12
+
13
+ /**
14
+ * Check if a path is executable.
15
+ */
16
+ async function isExecutable(path) {
17
+ try {
18
+ await access(path, constants.X_OK);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Tailscale env: strip XPC_SERVICE_NAME which can cause hangs in LaunchAgent contexts.
27
+ */
28
+ function tailscaleEnv() {
29
+ const env = { ...process.env };
30
+ delete env.XPC_SERVICE_NAME;
31
+ return env;
32
+ }
33
+
34
+ /**
35
+ * Resolve the tailscale CLI path.
36
+ *
37
+ * Priority:
38
+ * 1. HAPPY_LOCAL_TAILSCALE_BIN env override
39
+ * 2. PATH lookup
40
+ * 3. macOS app bundle paths
41
+ */
42
+ export async function resolveTailscaleCmd() {
43
+ // Explicit override
44
+ if (process.env.HAPPY_LOCAL_TAILSCALE_BIN?.trim()) {
45
+ return process.env.HAPPY_LOCAL_TAILSCALE_BIN.trim();
46
+ }
47
+
48
+ // Try PATH first
49
+ try {
50
+ const found = await resolveCommandPath('tailscale', { env: tailscaleEnv(), timeoutMs: TAILSCALE_TIMEOUT_MS });
51
+ if (found) return found;
52
+ } catch {
53
+ // ignore
54
+ }
55
+
56
+ // macOS app bundle paths
57
+ const appCliPath = '/Applications/Tailscale.app/Contents/MacOS/tailscale';
58
+ if (await isExecutable(appCliPath)) return appCliPath;
59
+
60
+ const appPath = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
61
+ if (await isExecutable(appPath)) return appPath;
62
+
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Get the local Tailscale IPv4 address.
68
+ *
69
+ * @returns {Promise<string | null>} The Tailscale IPv4 address, or null if unavailable.
70
+ */
71
+ export async function getTailscaleIpv4() {
72
+ const cmd = await resolveTailscaleCmd();
73
+ if (!cmd) return null;
74
+
75
+ const result = await runCaptureResult(cmd, ['ip', '-4'], {
76
+ env: tailscaleEnv(),
77
+ timeoutMs: TAILSCALE_TIMEOUT_MS,
78
+ });
79
+
80
+ if (!result.ok) return null;
81
+
82
+ const ip = result.out.trim().split('\n')[0]?.trim();
83
+ // Validate IPv4 format (basic check)
84
+ if (!ip || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) return null;
85
+
86
+ return ip;
87
+ }
88
+
89
+ /**
90
+ * Check if Tailscale is available and connected.
91
+ *
92
+ * @returns {Promise<boolean>}
93
+ */
94
+ export async function isTailscaleAvailable() {
95
+ const ip = await getTailscaleIpv4();
96
+ return Boolean(ip);
97
+ }
98
+
99
+ /**
100
+ * Get Tailscale status information.
101
+ *
102
+ * @returns {Promise<{ available: boolean, ip: string | null, error: string | null }>}
103
+ */
104
+ export async function getTailscaleStatus() {
105
+ const cmd = await resolveTailscaleCmd();
106
+ if (!cmd) {
107
+ return { available: false, ip: null, error: 'tailscale CLI not found' };
108
+ }
109
+
110
+ const ip = await getTailscaleIpv4();
111
+ if (!ip) {
112
+ return { available: false, ip: null, error: 'tailscale not connected or no IPv4 address' };
113
+ }
114
+
115
+ return { available: true, ip, error: null };
116
+ }
@@ -0,0 +1,39 @@
1
+ function supportsAnsi() {
2
+ if (!process.stdout.isTTY) return false;
3
+ if (process.env.NO_COLOR) return false;
4
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
5
+ return true;
6
+ }
7
+
8
+ function wrap(code, s) {
9
+ return supportsAnsi() ? `\x1b[${code}m${s}\x1b[0m` : String(s);
10
+ }
11
+
12
+ export function ansiEnabled() {
13
+ return supportsAnsi();
14
+ }
15
+
16
+ export function bold(s) {
17
+ return wrap('1', s);
18
+ }
19
+
20
+ export function dim(s) {
21
+ return wrap('2', s);
22
+ }
23
+
24
+ export function red(s) {
25
+ return wrap('31', s);
26
+ }
27
+
28
+ export function green(s) {
29
+ return wrap('32', s);
30
+ }
31
+
32
+ export function yellow(s) {
33
+ return wrap('33', s);
34
+ }
35
+
36
+ export function cyan(s) {
37
+ return wrap('36', s);
38
+ }
39
+
package/scripts/where.mjs CHANGED
@@ -26,8 +26,8 @@ async function main() {
26
26
  if (wantsHelp(argv, { flags }) || argv.includes('help')) {
27
27
  printResult({
28
28
  json,
29
- data: { flags: ['--json'], commands: ['where', 'env'] },
30
- text: ['[where] usage:', ' happys where [--json]', ' happys env [--json]'].join('\n'),
29
+ data: { flags: ['--json'], commands: ['where'] },
30
+ text: ['[where] usage:', ' happys where [--json]'].join('\n'),
31
31
  });
32
32
  return;
33
33
  }