happy-stacks 0.1.0 → 0.2.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 (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -0
  64. package/scripts/utils/dev_daemon.mjs +104 -0
  65. package/scripts/utils/dev_expo_web.mjs +112 -0
  66. package/scripts/utils/dev_server.mjs +183 -0
  67. package/scripts/utils/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -3,9 +3,12 @@ import { cp, mkdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
6
7
  import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
7
- import { parseArgs } from './utils/args.mjs';
8
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
+ import { parseArgs } from './utils/cli/args.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
+ import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
11
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
9
12
 
10
13
  async function ensureSwiftbarAssets({ cliRootDir }) {
11
14
  const homeDir = getHappyStacksHomeDir();
@@ -34,9 +37,20 @@ function openSwiftbarPluginsDir() {
34
37
  }
35
38
  }
36
39
 
37
- function removeSwiftbarPlugins() {
40
+ function sandboxPluginBasename() {
41
+ const sandboxDir = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
42
+ if (!sandboxDir) return '';
43
+ const hash = createHash('sha256').update(sandboxDir).digest('hex').slice(0, 10);
44
+ return `happy-stacks.sandbox-${hash}`;
45
+ }
46
+
47
+ function removeSwiftbarPlugins({ patterns }) {
48
+ const pats = (patterns ?? []).filter(Boolean);
49
+ const args = pats.length ? pats.map((p) => `"${p}"`).join(' ') : '"happy-stacks.*.sh" "happy-local.*.sh"';
38
50
  const s =
39
- 'DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; if [[ -z "$DIR" ]]; then DIR="$HOME/Library/Application Support/SwiftBar/Plugins"; fi; if [[ -d "$DIR" ]]; then rm -f "$DIR"/happy-stacks.*.sh "$DIR"/happy-local.*.sh 2>/dev/null || true; echo "$DIR"; else echo ""; fi';
51
+ `DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; ` +
52
+ `if [[ -z "$DIR" ]]; then DIR="$HOME/Library/Application Support/SwiftBar/Plugins"; fi; ` +
53
+ `if [[ -d "$DIR" ]]; then rm -f "$DIR"/${args} 2>/dev/null || true; echo "$DIR"; else echo ""; fi`;
40
54
  const res = spawnSync('bash', ['-lc', s], { encoding: 'utf-8' });
41
55
  if (res.status !== 0) {
42
56
  return null;
@@ -45,6 +59,14 @@ function removeSwiftbarPlugins() {
45
59
  return out || null;
46
60
  }
47
61
 
62
+ function normalizeMenubarMode(raw) {
63
+ const v = String(raw ?? '').trim().toLowerCase();
64
+ if (!v) return '';
65
+ if (v === 'selfhost' || v === 'self-host' || v === 'self_host' || v === 'host') return 'selfhost';
66
+ if (v === 'dev' || v === 'developer') return 'dev';
67
+ return '';
68
+ }
69
+
48
70
  async function main() {
49
71
  const rawArgv = process.argv.slice(2);
50
72
  const argv = rawArgv[0] === 'menubar' ? rawArgv.slice(1) : rawArgv;
@@ -55,16 +77,19 @@ async function main() {
55
77
  if (wantsHelp(argv, { flags }) || cmd === 'help') {
56
78
  printResult({
57
79
  json,
58
- data: { commands: ['install', 'uninstall', 'open'] },
80
+ data: { commands: ['install', 'uninstall', 'open', 'mode', 'status'] },
59
81
  text: [
60
82
  '[menubar] usage:',
61
83
  ' happys menubar install [--json]',
62
84
  ' happys menubar uninstall [--json]',
63
85
  ' happys menubar open [--json]',
86
+ ' happys menubar mode <selfhost|dev> [--json]',
87
+ ' happys menubar status [--json]',
64
88
  '',
65
89
  'notes:',
66
90
  ' - installs SwiftBar plugin into the active SwiftBar plugin folder',
67
- ' - keeps plugin source under ~/.happy-stacks/extras/swiftbar for stability',
91
+ ' - keeps plugin source under <homeDir>/extras/swiftbar for stability',
92
+ ' - sandbox mode: install/uninstall are disabled by default (set HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1 to override)',
68
93
  ].join('\n'),
69
94
  });
70
95
  return;
@@ -82,15 +107,63 @@ async function main() {
82
107
  }
83
108
 
84
109
  if (cmd === 'menubar:uninstall' || cmd === 'uninstall') {
85
- const dir = removeSwiftbarPlugins();
110
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
111
+ printResult({ json, data: { ok: true, skipped: 'sandbox' }, text: '[menubar] uninstall skipped (sandbox mode)' });
112
+ return;
113
+ }
114
+ const patterns = isSandboxed()
115
+ ? [`${sandboxPluginBasename()}.*.sh`]
116
+ : ['happy-stacks.*.sh', 'happy-local.*.sh'];
117
+ const dir = removeSwiftbarPlugins({ patterns });
86
118
  printResult({ json, data: { ok: true, pluginsDir: dir }, text: dir ? `[menubar] removed plugins from ${dir}` : '[menubar] no plugins dir found' });
87
119
  return;
88
120
  }
89
121
 
122
+ if (cmd === 'status') {
123
+ const mode = (process.env.HAPPY_STACKS_MENUBAR_MODE ?? process.env.HAPPY_LOCAL_MENUBAR_MODE ?? 'dev').trim() || 'dev';
124
+ printResult({ json, data: { ok: true, mode }, text: `[menubar] mode: ${mode}` });
125
+ return;
126
+ }
127
+
128
+ if (cmd === 'mode') {
129
+ const positionals = argv.filter((a) => !a.startsWith('--'));
130
+ const raw = positionals[1] ?? '';
131
+ const mode = normalizeMenubarMode(raw);
132
+ if (!mode) {
133
+ throw new Error('[menubar] usage: happys menubar mode <selfhost|dev> [--json]');
134
+ }
135
+ await ensureEnvLocalUpdated({
136
+ rootDir: cliRootDir,
137
+ updates: [
138
+ { key: 'HAPPY_STACKS_MENUBAR_MODE', value: mode },
139
+ { key: 'HAPPY_LOCAL_MENUBAR_MODE', value: mode },
140
+ ],
141
+ });
142
+ printResult({ json, data: { ok: true, mode }, text: `[menubar] mode set: ${mode}` });
143
+ return;
144
+ }
145
+
90
146
  if (cmd === 'menubar:install' || cmd === 'install') {
147
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
148
+ throw new Error(
149
+ '[menubar] install is disabled in sandbox mode.\n' +
150
+ 'Reason: SwiftBar plugin installation writes to a global user folder.\n' +
151
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
152
+ );
153
+ }
91
154
  const { destDir } = await ensureSwiftbarAssets({ cliRootDir });
92
155
  const installer = join(destDir, 'install.sh');
93
- const res = spawnSync('bash', [installer, '--force'], { stdio: 'inherit', env: { ...process.env, HAPPY_STACKS_HOME_DIR: getHappyStacksHomeDir() } });
156
+ const env = {
157
+ ...process.env,
158
+ HAPPY_STACKS_HOME_DIR: getHappyStacksHomeDir(),
159
+ ...(isSandboxed()
160
+ ? {
161
+ HAPPY_STACKS_SWIFTBAR_PLUGIN_BASENAME: sandboxPluginBasename(),
162
+ HAPPY_STACKS_SWIFTBAR_PLUGIN_WRAPPER: '1',
163
+ }
164
+ : {}),
165
+ };
166
+ const res = spawnSync('bash', [installer, '--force'], { stdio: 'inherit', env });
94
167
  if (res.status !== 0) {
95
168
  process.exit(res.status ?? 1);
96
169
  }
@@ -0,0 +1,302 @@
1
+ import './utils/env.mjs';
2
+ import { copyFile, mkdir, readFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ import { parseArgs } from './utils/cli/args.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
+ import { parseDotenv } from './utils/dotenv.mjs';
9
+ import { ensureEnvFileUpdated } from './utils/env_file.mjs';
10
+ import { resolveStackEnvPath } from './utils/paths.mjs';
11
+ import { ensureDepsInstalled } from './utils/pm.mjs';
12
+ import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/happy_server_infra.mjs';
13
+ import { runCapture } from './utils/proc.mjs';
14
+ import { pickNextFreeTcpPort } from './utils/ports.mjs';
15
+
16
+ function usage() {
17
+ return [
18
+ '[migrate] usage:',
19
+ ' happys migrate light-to-server --from-stack=<name> --to-stack=<name> [--include-files] [--force] [--json]',
20
+ '',
21
+ 'Notes:',
22
+ '- This migrates chat data from happy-server-light (SQLite) to happy-server (Postgres).',
23
+ '- It preserves IDs, so existing session URLs keep working on the new server.',
24
+ '- If --include-files is set, it mirrors server-light local files into Minio (S3) in the target stack.',
25
+ ].join('\n');
26
+ }
27
+
28
+ async function readEnvObject(envPath) {
29
+ try {
30
+ const raw = await readFile(envPath, 'utf-8');
31
+ return Object.fromEntries(parseDotenv(raw).entries());
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function getEnvValue(env, key) {
38
+ return (env?.[key] ?? '').toString().trim();
39
+ }
40
+
41
+ function parseFileDatabaseUrl(url) {
42
+ const raw = String(url ?? '').trim();
43
+ if (!raw) return null;
44
+ if (raw.startsWith('file:')) {
45
+ const path = raw.slice('file:'.length);
46
+ return { url: raw, path };
47
+ }
48
+ return null;
49
+ }
50
+
51
+ async function ensureTargetSecretMatchesSource({ sourceSecretPath, targetSecretPath }) {
52
+ try {
53
+ const src = (await readFile(sourceSecretPath, 'utf-8')).trim();
54
+ if (!src) return null;
55
+ await mkdir(join(targetSecretPath, '..'), { recursive: true }).catch(() => {});
56
+ const { rename, writeFile } = await import('node:fs/promises');
57
+ // Write with a trailing newline, via atomic replace.
58
+ const tmp = join(join(targetSecretPath, '..'), `.handy-master-secret.${Date.now()}.tmp`);
59
+ await writeFile(tmp, src + '\n', { encoding: 'utf-8', mode: 0o600 });
60
+ await rename(tmp, targetSecretPath);
61
+ return src;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ async function importPrismaClientFrom(dir) {
68
+ const req = createRequire(import.meta.url);
69
+ const resolved = req.resolve('@prisma/client', { paths: [dir] });
70
+ // eslint-disable-next-line import/no-dynamic-require
71
+ const mod = req(resolved);
72
+ return mod.PrismaClient;
73
+ }
74
+
75
+ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles, force, json }) {
76
+ const from = resolveStackEnvPath(fromStack);
77
+ const to = resolveStackEnvPath(toStack);
78
+
79
+ const fromEnv = await readEnvObject(from.envPath);
80
+ const toEnv = await readEnvObject(to.envPath);
81
+
82
+ const fromFlavor = getEnvValue(fromEnv, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(fromEnv, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
83
+ const toFlavor = getEnvValue(toEnv, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(toEnv, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
84
+
85
+ if (fromFlavor !== 'happy-server-light') {
86
+ throw new Error(`[migrate] from-stack must use happy-server-light (got: ${fromFlavor})`);
87
+ }
88
+ if (toFlavor !== 'happy-server') {
89
+ throw new Error(`[migrate] to-stack must use happy-server (got: ${toFlavor})`);
90
+ }
91
+
92
+ const fromDataDir = getEnvValue(fromEnv, 'HAPPY_SERVER_LIGHT_DATA_DIR') || join(from.baseDir, 'server-light');
93
+ const fromFilesDir = getEnvValue(fromEnv, 'HAPPY_SERVER_LIGHT_FILES_DIR') || join(fromDataDir, 'files');
94
+ const fromDbUrl = getEnvValue(fromEnv, 'DATABASE_URL') || `file:${join(fromDataDir, 'happy-server-light.sqlite')}`;
95
+ const fromParsed = parseFileDatabaseUrl(fromDbUrl);
96
+ if (!fromParsed?.path) {
97
+ throw new Error(`[migrate] from-stack DATABASE_URL must be file:... (got: ${fromDbUrl})`);
98
+ }
99
+
100
+ const toPortRaw = getEnvValue(toEnv, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(toEnv, 'HAPPY_LOCAL_SERVER_PORT');
101
+ let toPort = toPortRaw ? Number(toPortRaw) : NaN;
102
+ const toEphemeral = !toPortRaw;
103
+ if (!Number.isFinite(toPort) || toPort <= 0) {
104
+ // Ephemeral-port stacks don't pin ports in env. Pick a free port for this one-off migration run.
105
+ toPort = await pickNextFreeTcpPort(3005);
106
+ if (!json) {
107
+ // eslint-disable-next-line no-console
108
+ console.log(`[migrate] to-stack has no pinned port; using ephemeral port ${toPort} for this migration run`);
109
+ }
110
+ }
111
+
112
+ // Ensure target secret is the same as source so auth tokens remain valid after migration.
113
+ const sourceSecretPath = join(fromDataDir, 'handy-master-secret.txt');
114
+ const targetSecretPath = getEnvValue(toEnv, 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE') || join(to.baseDir, 'happy-server', 'handy-master-secret.txt');
115
+ await ensureTargetSecretMatchesSource({ sourceSecretPath, targetSecretPath });
116
+ await ensureEnvFileUpdated({
117
+ envPath: to.envPath,
118
+ updates: [{ key: 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE', value: targetSecretPath }],
119
+ });
120
+
121
+ // Resolve component dirs (prefer stack-pinned dirs).
122
+ const lightDir = getEnvValue(fromEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT') || getEnvValue(fromEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT');
123
+ const fullDir = getEnvValue(toEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER') || getEnvValue(toEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER');
124
+ if (!lightDir || !fullDir) {
125
+ throw new Error('[migrate] missing component dirs in stack env (expected HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT and HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER)');
126
+ }
127
+
128
+ await ensureDepsInstalled(lightDir, 'happy-server-light');
129
+ await ensureDepsInstalled(fullDir, 'happy-server');
130
+
131
+ // Bring up infra and ensure env vars are present.
132
+ const infra = await ensureHappyServerManagedInfra({
133
+ stackName: toStack,
134
+ baseDir: to.baseDir,
135
+ serverPort: toPort,
136
+ publicServerUrl: `http://127.0.0.1:${toPort}`,
137
+ envPath: to.envPath,
138
+ env: {
139
+ ...process.env,
140
+ ...(toEphemeral ? { HAPPY_STACKS_EPHEMERAL_PORTS: '1', HAPPY_LOCAL_EPHEMERAL_PORTS: '1' } : {}),
141
+ },
142
+ });
143
+ await applyHappyServerMigrations({ serverDir: fullDir, env: { ...process.env, ...infra.env } });
144
+
145
+ // Copy sqlite DB to a snapshot so migration is consistent even if the source server is running.
146
+ const snapshotDir = join(to.baseDir, 'migrations');
147
+ await mkdir(snapshotDir, { recursive: true });
148
+ const snapshotPath = join(snapshotDir, `happy-server-light.${basename(fromParsed.path)}.${Date.now()}.sqlite`);
149
+ await copyFile(fromParsed.path, snapshotPath);
150
+ const snapshotDbUrl = `file:${snapshotPath}`;
151
+
152
+ const SourcePrismaClient = await importPrismaClientFrom(lightDir);
153
+ const TargetPrismaClient = await importPrismaClientFrom(fullDir);
154
+
155
+ const sourceDb = new SourcePrismaClient({ datasources: { db: { url: snapshotDbUrl } } });
156
+ const targetDb = new TargetPrismaClient({ datasources: { db: { url: infra.env.DATABASE_URL } } });
157
+
158
+ try {
159
+ // Fail-fast unless target is empty (keeps this safe).
160
+ const existingSessions = await targetDb.session.count();
161
+ const existingMessages = await targetDb.sessionMessage.count();
162
+ if (!force && (existingSessions > 0 || existingMessages > 0)) {
163
+ throw new Error(
164
+ `[migrate] target database is not empty (sessions=${existingSessions}, messages=${existingMessages}).\n` +
165
+ `Pass --force to attempt a merge (skipDuplicates), or migrate into a fresh stack.`
166
+ );
167
+ }
168
+
169
+ // Core entities
170
+ const accounts = await sourceDb.account.findMany();
171
+ if (accounts.length) {
172
+ await targetDb.account.createMany({ data: accounts, skipDuplicates: true });
173
+ }
174
+
175
+ const machines = await sourceDb.machine.findMany();
176
+ if (machines.length) {
177
+ await targetDb.machine.createMany({ data: machines, skipDuplicates: true });
178
+ }
179
+
180
+ const accessKeys = await sourceDb.accessKey.findMany();
181
+ if (accessKeys.length) {
182
+ await targetDb.accessKey.createMany({ data: accessKeys, skipDuplicates: true });
183
+ }
184
+
185
+ const sessions = await sourceDb.session.findMany();
186
+ if (sessions.length) {
187
+ await targetDb.session.createMany({ data: sessions, skipDuplicates: true });
188
+ }
189
+
190
+ // Messages: stream in batches to avoid high memory.
191
+ let migrated = 0;
192
+ const batchSize = 1000;
193
+ let cursor = null;
194
+ while (true) {
195
+ // eslint-disable-next-line no-await-in-loop
196
+ const page = await sourceDb.sessionMessage.findMany({
197
+ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
198
+ orderBy: { id: 'asc' },
199
+ take: batchSize,
200
+ });
201
+ if (!page.length) break;
202
+ cursor = page[page.length - 1].id;
203
+ // eslint-disable-next-line no-await-in-loop
204
+ await targetDb.sessionMessage.createMany({ data: page, skipDuplicates: true });
205
+ migrated += page.length;
206
+ // eslint-disable-next-line no-console
207
+ if (!json && migrated % (batchSize * 20) === 0) console.log(`[migrate] migrated ${migrated} messages...`);
208
+ }
209
+
210
+ // Pending queue (small)
211
+ const pending = await sourceDb.sessionPendingMessage.findMany();
212
+ if (pending.length) {
213
+ await targetDb.sessionPendingMessage.createMany({ data: pending, skipDuplicates: true });
214
+ }
215
+
216
+ if (includeFiles) {
217
+ // Mirror server-light local files (public/*) into Minio bucket root.
218
+ // This assumes server-light stored public files under HAPPY_SERVER_LIGHT_FILES_DIR/public/...
219
+ // (Matches happy-server Minio object keys).
220
+ const { composePath, projectName } = infra;
221
+ await runCapture('docker', [
222
+ 'compose',
223
+ '-f',
224
+ composePath,
225
+ '-p',
226
+ projectName,
227
+ 'run',
228
+ '--rm',
229
+ '-T',
230
+ '-v',
231
+ `${fromFilesDir}:/src:ro`,
232
+ 'minio-init',
233
+ 'sh',
234
+ '-lc',
235
+ [
236
+ `mc alias set local http://minio:9000 ${infra.env.S3_ACCESS_KEY} ${infra.env.S3_SECRET_KEY}`,
237
+ `mc mirror --overwrite /src local/${infra.env.S3_BUCKET}`,
238
+ ].join(' && '),
239
+ ]);
240
+ }
241
+
242
+ printResult({
243
+ json,
244
+ data: {
245
+ ok: true,
246
+ fromStack,
247
+ toStack,
248
+ snapshotPath,
249
+ migrated: { accounts: accounts.length, sessions: sessions.length, messages: migrated, machines: machines.length, accessKeys: accessKeys.length },
250
+ filesMirrored: Boolean(includeFiles),
251
+ },
252
+ text: [
253
+ `[migrate] ok`,
254
+ `[migrate] from: ${fromStack} (${fromFlavor})`,
255
+ `[migrate] to: ${toStack} (${toFlavor})`,
256
+ `[migrate] sqlite snapshot: ${snapshotPath}`,
257
+ `[migrate] messages: ${migrated}`,
258
+ includeFiles ? `[migrate] files: mirrored from ${fromFilesDir} -> minio bucket ${infra.env.S3_BUCKET}` : `[migrate] files: skipped`,
259
+ ].join('\n'),
260
+ });
261
+ } finally {
262
+ await sourceDb.$disconnect().catch(() => {});
263
+ await targetDb.$disconnect().catch(() => {});
264
+ }
265
+ }
266
+
267
+ async function main() {
268
+ const argv = process.argv.slice(2);
269
+ const { flags, kv } = parseArgs(argv);
270
+ const json = wantsJson(argv, { flags });
271
+ if (wantsHelp(argv, { flags })) {
272
+ printResult({ json, data: { ok: true }, text: usage() });
273
+ return;
274
+ }
275
+
276
+ const cmd = argv.find((a) => !a.startsWith('--')) ?? '';
277
+ if (!cmd) {
278
+ throw new Error(usage());
279
+ }
280
+
281
+ if (cmd !== 'light-to-server') {
282
+ throw new Error(`[migrate] unknown subcommand: ${cmd}\n\n${usage()}`);
283
+ }
284
+
285
+ const fromStack = (kv.get('--from-stack') ?? 'main').trim();
286
+ const toStack = (kv.get('--to-stack') ?? '').trim();
287
+ const includeFiles = flags.has('--include-files') || (kv.get('--include-files') ?? '').trim() === '1';
288
+ const force = flags.has('--force');
289
+ if (!toStack) {
290
+ throw new Error('[migrate] --to-stack is required');
291
+ }
292
+
293
+ const rootDir = (await import('./utils/paths.mjs')).getRootDir(import.meta.url);
294
+ await migrateLightToServer({ rootDir, fromStack, toStack, includeFiles, force, json });
295
+ }
296
+
297
+ main().catch((err) => {
298
+ // eslint-disable-next-line no-console
299
+ console.error(err);
300
+ process.exit(1);
301
+ });
302
+
@@ -1,10 +1,12 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { killPortListeners } from './utils/ports.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { pickNextFreeTcpPort } from './utils/ports.mjs';
4
4
  import { run, runCapture, spawnProc } from './utils/proc.mjs';
5
- import { getComponentDir, getRootDir } from './utils/paths.mjs';
6
- import { ensureDepsInstalled, requireDir } from './utils/pm.mjs';
7
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
5
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
+ import { ensureDepsInstalled, pmExecBin, pmSpawnBin, requireDir } from './utils/pm.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
+ import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo.mjs';
9
+ import { killProcessGroupOwnedByStack } from './utils/ownership.mjs';
8
10
 
9
11
  /**
10
12
  * Mobile dev helper for the embedded `components/happy` Expo app.
@@ -25,6 +27,7 @@ async function main() {
25
27
  const argv = process.argv.slice(2);
26
28
  const { flags, kv } = parseArgs(argv);
27
29
  const json = wantsJson(argv, { flags });
30
+ const restart = flags.has('--restart');
28
31
 
29
32
  if (wantsHelp(argv, { flags })) {
30
33
  printResult({
@@ -40,6 +43,7 @@ async function main() {
40
43
  '--prebuild [--platform=ios|all] [--clean]',
41
44
  '--run-ios [--device=<id-or-name>] [--configuration=Debug|Release]',
42
45
  '--metro / --no-metro',
46
+ '--restart',
43
47
  '--no-signing-fix',
44
48
  ],
45
49
  json: true,
@@ -47,6 +51,7 @@ async function main() {
47
51
  text: [
48
52
  '[mobile] usage:',
49
53
  ' happys mobile [--host=lan|localhost|tunnel] [--port=8081] [--scheme=...] [--json]',
54
+ ' happys mobile --restart # force-restart Metro for this stack/worktree',
50
55
  ' happys mobile --run-ios [--device=...] [--configuration=Debug|Release]',
51
56
  ' happys mobile --prebuild [--platform=ios|all] [--clean]',
52
57
  ' happys mobile --no-metro # just build/install (if --run-ios) without starting Metro',
@@ -107,7 +112,7 @@ async function main() {
107
112
  process.env.HAPPY_LOCAL_MOBILE_SCHEME ??
108
113
  iosBundleId;
109
114
  const host = kv.get('--host') ?? process.env.HAPPY_STACKS_MOBILE_HOST ?? process.env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan';
110
- const port = kv.get('--port') ?? process.env.HAPPY_STACKS_MOBILE_PORT ?? process.env.HAPPY_LOCAL_MOBILE_PORT ?? '8081';
115
+ const portRaw = kv.get('--port') ?? process.env.HAPPY_STACKS_MOBILE_PORT ?? process.env.HAPPY_LOCAL_MOBILE_PORT ?? '8081';
111
116
  // Default behavior:
112
117
  // - `happys mobile` starts Metro and keeps running.
113
118
  // - `happys mobile --run-ios` / `happys mobile:ios` just builds/installs and exits (unless --metro is provided).
@@ -120,6 +125,20 @@ async function main() {
120
125
  APP_ENV: appEnv,
121
126
  };
122
127
 
128
+ const autostart = getDefaultAutostartPaths();
129
+ const mobilePaths = getExpoStatePaths({
130
+ baseDir: autostart.baseDir,
131
+ kind: 'mobile-dev',
132
+ projectDir: uiDir,
133
+ stateFileName: 'mobile.state.json',
134
+ });
135
+ await ensureExpoIsolationEnv({
136
+ env,
137
+ stateDir: mobilePaths.stateDir,
138
+ expoHomeDir: mobilePaths.expoHomeDir,
139
+ tmpDir: mobilePaths.tmpDir,
140
+ });
141
+
123
142
  // Allow happy-stacks to define the default server URL baked into the app bundle.
124
143
  // This is read by the app via `process.env.EXPO_PUBLIC_HAPPY_SERVER_URL`.
125
144
  const stacksServerUrl =
@@ -139,7 +158,7 @@ async function main() {
139
158
  iosBundleId,
140
159
  scheme,
141
160
  host,
142
- port,
161
+ port: portRaw,
143
162
  shouldPrebuild: flags.has('--prebuild'),
144
163
  shouldRunIos: flags.has('--run-ios'),
145
164
  shouldStartMetro,
@@ -155,11 +174,11 @@ async function main() {
155
174
  const shouldClean = flags.has('--clean');
156
175
  // Prebuild can fail during `pod install` if deployment target mismatches.
157
176
  // We skip installs, patch deployment target + RN build mode, then run `pod install` ourselves.
158
- const prebuildArgs = ['expo', 'prebuild', '--no-install', '--platform', platform];
177
+ const prebuildArgs = ['prebuild', '--no-install', '--platform', platform];
159
178
  if (shouldClean) {
160
179
  prebuildArgs.push('--clean');
161
180
  }
162
- await run('npx', prebuildArgs, { cwd: uiDir, env });
181
+ await pmExecBin({ dir: uiDir, bin: 'expo', args: prebuildArgs, env });
163
182
 
164
183
  // Always patch iOS props if iOS was generated.
165
184
  if (platform === 'ios' || platform === 'all') {
@@ -266,35 +285,54 @@ async function main() {
266
285
  }
267
286
 
268
287
  const configuration = kv.get('--configuration') ?? 'Debug';
269
- const args = ['expo', 'run:ios', '--no-bundler', '--no-build-cache', '--configuration', configuration];
288
+ const args = ['run:ios', '--no-bundler', '--no-build-cache', '--configuration', configuration];
270
289
  if (device) {
271
290
  args.push('-d', device);
272
291
  }
273
292
  // Ensure CocoaPods doesn't crash due to locale issues.
274
293
  env.LANG = env.LANG ?? 'en_US.UTF-8';
275
294
  env.LC_ALL = env.LC_ALL ?? 'en_US.UTF-8';
276
- await run('npx', args, { cwd: uiDir, env });
295
+ await pmExecBin({ dir: uiDir, bin: 'expo', args, env });
277
296
  }
278
297
 
279
298
  if (!shouldStartMetro) {
280
299
  return;
281
300
  }
282
301
 
283
- const portNumber = Number.parseInt(port, 10);
284
- if (Number.isFinite(portNumber) && portNumber > 0) {
285
- await killPortListeners(portNumber, { label: 'expo' });
302
+ const running = await isStateProcessRunning(mobilePaths.statePath);
303
+ if (!restart && running.running) {
304
+ // eslint-disable-next-line no-console
305
+ console.log(`[mobile] Metro already running for this stack/worktree (pid=${running.state.pid}, port=${running.state.port})`);
306
+ return;
307
+ }
308
+ if (restart && running.state?.pid) {
309
+ const prevPid = Number(running.state.pid);
310
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || autostart.stackName;
311
+ const envPath = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
312
+ const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-mobile', json: true });
313
+ if (!res.killed) {
314
+ // eslint-disable-next-line no-console
315
+ console.warn(
316
+ `[mobile] not stopping existing Metro pid=${prevPid} because it does not look stack-owned.\n` +
317
+ `[mobile] continuing by starting a new Metro on a free port.`
318
+ );
319
+ }
286
320
  }
287
321
 
322
+ const requestedPort = Number.parseInt(String(portRaw), 10);
323
+ const startPort = Number.isFinite(requestedPort) && requestedPort > 0 ? requestedPort : 8081;
324
+ const portNumber = await pickNextFreeTcpPort(startPort);
325
+ env.RCT_METRO_PORT = String(portNumber);
326
+
288
327
  // Start Metro for a dev client.
289
328
  // The critical part is --scheme: without it, Expo defaults to `exp+<slug>` (here `exp+happy`)
290
329
  // which the App Store app also registers, so iOS can open the wrong app.
291
- spawnProc(
292
- 'mobile',
293
- 'npx',
294
- ['expo', 'start', '--dev-client', '--host', host, '--port', port, '--scheme', scheme],
295
- env,
296
- { cwd: uiDir }
297
- );
330
+ const args = ['start', '--dev-client', '--host', host, '--port', String(portNumber), '--scheme', scheme];
331
+ if (wantsExpoClearCache({ env })) {
332
+ args.push('--clear');
333
+ }
334
+ const child = await pmSpawnBin({ label: 'mobile', dir: uiDir, bin: 'expo', args, env });
335
+ await writePidState(mobilePaths.statePath, { pid: child.pid, port: portNumber, uiDir, startedAt: new Date().toISOString() });
298
336
 
299
337
  await new Promise(() => {});
300
338
  }