happy-stacks 0.0.0 → 0.1.2

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 (42) hide show
  1. package/README.md +22 -4
  2. package/bin/happys.mjs +76 -5
  3. package/docs/server-flavors.md +61 -2
  4. package/docs/stacks.md +16 -4
  5. package/extras/swiftbar/auth-login.sh +5 -5
  6. package/extras/swiftbar/happy-stacks.5s.sh +83 -41
  7. package/extras/swiftbar/happys-term.sh +151 -0
  8. package/extras/swiftbar/happys.sh +52 -0
  9. package/extras/swiftbar/lib/render.sh +74 -56
  10. package/extras/swiftbar/lib/system.sh +37 -6
  11. package/extras/swiftbar/lib/utils.sh +180 -4
  12. package/extras/swiftbar/pnpm-term.sh +2 -122
  13. package/extras/swiftbar/pnpm.sh +2 -13
  14. package/extras/swiftbar/set-server-flavor.sh +8 -8
  15. package/extras/swiftbar/wt-pr.sh +1 -1
  16. package/package.json +1 -1
  17. package/scripts/auth.mjs +374 -3
  18. package/scripts/daemon.mjs +78 -11
  19. package/scripts/dev.mjs +122 -17
  20. package/scripts/init.mjs +238 -32
  21. package/scripts/migrate.mjs +292 -0
  22. package/scripts/mobile.mjs +51 -19
  23. package/scripts/run.mjs +118 -26
  24. package/scripts/service.mjs +176 -37
  25. package/scripts/stack.mjs +665 -22
  26. package/scripts/stop.mjs +157 -0
  27. package/scripts/tailscale.mjs +147 -21
  28. package/scripts/typecheck.mjs +145 -0
  29. package/scripts/ui_gateway.mjs +248 -0
  30. package/scripts/uninstall.mjs +3 -3
  31. package/scripts/utils/cli_registry.mjs +23 -0
  32. package/scripts/utils/config.mjs +9 -1
  33. package/scripts/utils/env.mjs +37 -15
  34. package/scripts/utils/expo.mjs +94 -0
  35. package/scripts/utils/happy_server_infra.mjs +430 -0
  36. package/scripts/utils/pm.mjs +11 -2
  37. package/scripts/utils/ports.mjs +51 -13
  38. package/scripts/utils/proc.mjs +46 -5
  39. package/scripts/utils/server.mjs +37 -0
  40. package/scripts/utils/stack_stop.mjs +206 -0
  41. package/scripts/utils/validate.mjs +42 -1
  42. package/scripts/worktrees.mjs +53 -7
package/scripts/init.mjs CHANGED
@@ -1,14 +1,25 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { mkdir, writeFile, readFile } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { spawnSync } from 'node:child_process';
6
- import { ensureHomeEnvUpdated } from './utils/config.mjs';
7
+ import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/config.mjs';
8
+ import { parseDotenv } from './utils/dotenv.mjs';
7
9
 
8
10
  function expandHome(p) {
9
11
  return p.replace(/^~(?=\/)/, homedir());
10
12
  }
11
13
 
14
+ async function readJsonIfExists(path) {
15
+ try {
16
+ const raw = await readFile(path, 'utf-8');
17
+ return JSON.parse(raw);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
12
23
  function getCliRootDir() {
13
24
  return dirname(dirname(fileURLToPath(import.meta.url)));
14
25
  }
@@ -22,6 +33,47 @@ function parseArgValue(argv, key) {
22
33
  return null;
23
34
  }
24
35
 
36
+ function firstNonEmpty(...values) {
37
+ for (const v of values) {
38
+ const s = (v ?? '').trim();
39
+ if (s) return s;
40
+ }
41
+ return '';
42
+ }
43
+
44
+ async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
45
+ try {
46
+ const contents = await readFile(path, 'utf-8');
47
+ const parsed = parseDotenv(contents);
48
+ for (const [k, v] of parsed.entries()) {
49
+ const allowOverride = override && (!overridePrefix || k.startsWith(overridePrefix));
50
+ if (allowOverride || process.env[k] == null || process.env[k] === '') {
51
+ process.env[k] = v;
52
+ }
53
+ }
54
+ } catch {
55
+ // ignore missing/invalid env file
56
+ }
57
+ }
58
+
59
+ function isWorkspaceBootstrapped(workspaceDir) {
60
+ // Heuristic: if the expected component repos exist in the workspace, we consider bootstrap "already done"
61
+ // and avoid re-running the interactive bootstrap wizard from `happys init`.
62
+ //
63
+ // Users can always re-run bootstrap explicitly:
64
+ // happys bootstrap --interactive
65
+ try {
66
+ const componentsDir = join(workspaceDir, 'components');
67
+ const ui = join(componentsDir, 'happy', 'package.json');
68
+ const cli = join(componentsDir, 'happy-cli', 'package.json');
69
+ const serverLight = join(componentsDir, 'happy-server-light', 'package.json');
70
+ const serverFull = join(componentsDir, 'happy-server', 'package.json');
71
+ return existsSync(ui) && existsSync(cli) && (existsSync(serverLight) || existsSync(serverFull));
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
25
77
  async function writeExecutable(path, contents) {
26
78
  await writeFile(path, contents, { mode: 0o755 });
27
79
  }
@@ -87,31 +139,108 @@ async function main() {
87
139
  if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
88
140
  console.log([
89
141
  '[init] usage:',
90
- ' happys init [--home-dir=/path] [--workspace-dir=/path] [--runtime-dir=/path] [--install-path] [--no-runtime] [--no-bootstrap] [--] [bootstrap args...]',
142
+ ' happys init [--home-dir=/path] [--workspace-dir=/path] [--runtime-dir=/path] [--storage-dir=/path] [--cli-root-dir=/path] [--tailscale-bin=/path] [--tailscale-cmd-timeout-ms=MS] [--tailscale-enable-timeout-ms=MS] [--tailscale-enable-timeout-ms-auto=MS] [--tailscale-reset-timeout-ms=MS] [--install-path] [--no-runtime] [--force-runtime] [--no-bootstrap] [--] [bootstrap args...]',
91
143
  '',
92
144
  'notes:',
93
145
  ' - writes ~/.happy-stacks/.env (stable pointer file)',
94
146
  ' - default workspace: ~/.happy-stacks/workspace',
95
147
  ' - default runtime: ~/.happy-stacks/runtime (recommended for services/SwiftBar)',
148
+ ' - runtime install is skipped if the same version is already installed (use --force-runtime to reinstall)',
149
+ ' - set HAPPY_STACKS_INIT_NO_RUNTIME=1 to persist skipping runtime installs on this machine',
96
150
  ' - optional: --install-path adds ~/.happy-stacks/bin to your shell PATH (idempotent)',
97
- ' - by default, runs `happys bootstrap --interactive` at the end (TTY only)',
151
+ ' - by default, runs `happys bootstrap --interactive` at the end (TTY only) IF components are not already present',
98
152
  ].join('\n'));
99
153
  return;
100
154
  }
101
155
 
102
156
  const cliRootDir = getCliRootDir();
103
157
 
158
+ // Important: `happys init` must be idempotent and must not "forget" custom dirs from a prior install.
159
+ //
160
+ // Other scripts load this pointer via `scripts/utils/env.mjs`, but `init.mjs` is often run before
161
+ // anything else (or directly from a repo checkout). So we load it here too.
162
+ const canonicalEnvPath = join(homedir(), '.happy-stacks', '.env');
163
+ if (existsSync(canonicalEnvPath)) {
164
+ await loadEnvFile(canonicalEnvPath, { override: false });
165
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
166
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_LOCAL_' });
167
+ }
168
+
104
169
  const homeDirRaw = parseArgValue(argv, 'home-dir');
105
- const homeDir = expandHome((homeDirRaw ?? '').trim() || (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() || join(homedir(), '.happy-stacks'));
170
+ const homeDir = expandHome(firstNonEmpty(
171
+ homeDirRaw,
172
+ process.env.HAPPY_STACKS_HOME_DIR,
173
+ process.env.HAPPY_LOCAL_HOME_DIR,
174
+ join(homedir(), '.happy-stacks'),
175
+ ));
106
176
  process.env.HAPPY_STACKS_HOME_DIR = homeDir;
177
+ process.env.HAPPY_LOCAL_HOME_DIR = process.env.HAPPY_LOCAL_HOME_DIR ?? homeDir;
107
178
 
108
179
  const workspaceDirRaw = parseArgValue(argv, 'workspace-dir');
109
- const workspaceDir = expandHome((workspaceDirRaw ?? '').trim() || join(homeDir, 'workspace'));
110
- process.env.HAPPY_STACKS_WORKSPACE_DIR = process.env.HAPPY_STACKS_WORKSPACE_DIR ?? workspaceDir;
180
+ const workspaceDir = expandHome(firstNonEmpty(
181
+ workspaceDirRaw,
182
+ process.env.HAPPY_STACKS_WORKSPACE_DIR,
183
+ process.env.HAPPY_LOCAL_WORKSPACE_DIR,
184
+ join(homeDir, 'workspace'),
185
+ ));
186
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = workspaceDir;
187
+ process.env.HAPPY_LOCAL_WORKSPACE_DIR = process.env.HAPPY_LOCAL_WORKSPACE_DIR ?? workspaceDir;
111
188
 
112
189
  const runtimeDirRaw = parseArgValue(argv, 'runtime-dir');
113
- const runtimeDir = expandHome((runtimeDirRaw ?? '').trim() || (process.env.HAPPY_STACKS_RUNTIME_DIR ?? '').trim() || join(homeDir, 'runtime'));
114
- process.env.HAPPY_STACKS_RUNTIME_DIR = process.env.HAPPY_STACKS_RUNTIME_DIR ?? runtimeDir;
190
+ const runtimeDir = expandHome(firstNonEmpty(
191
+ runtimeDirRaw,
192
+ process.env.HAPPY_STACKS_RUNTIME_DIR,
193
+ process.env.HAPPY_LOCAL_RUNTIME_DIR,
194
+ join(homeDir, 'runtime'),
195
+ ));
196
+ process.env.HAPPY_STACKS_RUNTIME_DIR = runtimeDir;
197
+ process.env.HAPPY_LOCAL_RUNTIME_DIR = process.env.HAPPY_LOCAL_RUNTIME_DIR ?? runtimeDir;
198
+
199
+ const storageDirRaw = parseArgValue(argv, 'storage-dir');
200
+ const storageDirOverride = expandHome((storageDirRaw ?? '').trim());
201
+ if (storageDirOverride) {
202
+ process.env.HAPPY_STACKS_STORAGE_DIR = process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDirOverride;
203
+ }
204
+
205
+ const cliRootDirRaw = parseArgValue(argv, 'cli-root-dir');
206
+ const cliRootDirOverride = expandHome((cliRootDirRaw ?? '').trim());
207
+ if (cliRootDirOverride) {
208
+ process.env.HAPPY_STACKS_CLI_ROOT_DIR = process.env.HAPPY_STACKS_CLI_ROOT_DIR ?? cliRootDirOverride;
209
+ }
210
+
211
+ const tailscaleBinRaw = parseArgValue(argv, 'tailscale-bin');
212
+ const tailscaleBinOverride = expandHome((tailscaleBinRaw ?? '').trim());
213
+ if (tailscaleBinOverride) {
214
+ process.env.HAPPY_STACKS_TAILSCALE_BIN = process.env.HAPPY_STACKS_TAILSCALE_BIN ?? tailscaleBinOverride;
215
+ }
216
+
217
+ const tailscaleCmdTimeoutMsRaw = parseArgValue(argv, 'tailscale-cmd-timeout-ms');
218
+ const tailscaleCmdTimeoutMsOverride = (tailscaleCmdTimeoutMsRaw ?? '').trim();
219
+ if (tailscaleCmdTimeoutMsOverride) {
220
+ process.env.HAPPY_STACKS_TAILSCALE_CMD_TIMEOUT_MS =
221
+ process.env.HAPPY_STACKS_TAILSCALE_CMD_TIMEOUT_MS ?? tailscaleCmdTimeoutMsOverride;
222
+ }
223
+
224
+ const tailscaleEnableTimeoutMsRaw = parseArgValue(argv, 'tailscale-enable-timeout-ms');
225
+ const tailscaleEnableTimeoutMsOverride = (tailscaleEnableTimeoutMsRaw ?? '').trim();
226
+ if (tailscaleEnableTimeoutMsOverride) {
227
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS =
228
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS ?? tailscaleEnableTimeoutMsOverride;
229
+ }
230
+
231
+ const tailscaleEnableTimeoutMsAutoRaw = parseArgValue(argv, 'tailscale-enable-timeout-ms-auto');
232
+ const tailscaleEnableTimeoutMsAutoOverride = (tailscaleEnableTimeoutMsAutoRaw ?? '').trim();
233
+ if (tailscaleEnableTimeoutMsAutoOverride) {
234
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS_AUTO =
235
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS_AUTO ?? tailscaleEnableTimeoutMsAutoOverride;
236
+ }
237
+
238
+ const tailscaleResetTimeoutMsRaw = parseArgValue(argv, 'tailscale-reset-timeout-ms');
239
+ const tailscaleResetTimeoutMsOverride = (tailscaleResetTimeoutMsRaw ?? '').trim();
240
+ if (tailscaleResetTimeoutMsOverride) {
241
+ process.env.HAPPY_STACKS_TAILSCALE_RESET_TIMEOUT_MS =
242
+ process.env.HAPPY_STACKS_TAILSCALE_RESET_TIMEOUT_MS ?? tailscaleResetTimeoutMsOverride;
243
+ }
115
244
 
116
245
  const nodePath = process.execPath;
117
246
 
@@ -121,30 +250,53 @@ async function main() {
121
250
  await mkdir(runtimeDir, { recursive: true });
122
251
  await mkdir(join(homeDir, 'bin'), { recursive: true });
123
252
 
124
- await ensureHomeEnvUpdated({
125
- updates: [
126
- { key: 'HAPPY_STACKS_HOME_DIR', value: homeDir },
127
- { key: 'HAPPY_STACKS_WORKSPACE_DIR', value: workspaceDir },
128
- { key: 'HAPPY_STACKS_RUNTIME_DIR', value: runtimeDir },
129
- { key: 'HAPPY_STACKS_NODE', value: nodePath },
130
- ],
131
- });
253
+ const pointerUpdates = [
254
+ { key: 'HAPPY_STACKS_HOME_DIR', value: homeDir },
255
+ { key: 'HAPPY_STACKS_WORKSPACE_DIR', value: workspaceDir },
256
+ { key: 'HAPPY_STACKS_RUNTIME_DIR', value: runtimeDir },
257
+ { key: 'HAPPY_STACKS_NODE', value: nodePath },
258
+ ];
259
+ if (storageDirOverride) {
260
+ pointerUpdates.push({ key: 'HAPPY_STACKS_STORAGE_DIR', value: storageDirOverride });
261
+ }
262
+ if (cliRootDirOverride) {
263
+ pointerUpdates.push({ key: 'HAPPY_STACKS_CLI_ROOT_DIR', value: cliRootDirOverride });
264
+ }
132
265
 
133
- const installRuntime = !argv.includes('--no-runtime');
266
+ // Write the "real" home env (used by runtime + scripts), AND a stable pointer at ~/.happy-stacks/.env.
267
+ // The pointer file allows launchd/SwiftBar/minimal shells to discover the actual install location
268
+ // even when no env vars are exported.
269
+ await ensureHomeEnvUpdated({ updates: pointerUpdates });
270
+ await ensureCanonicalHomeEnvUpdated({ updates: pointerUpdates });
271
+
272
+ const initNoRuntimeRaw = (process.env.HAPPY_STACKS_INIT_NO_RUNTIME ?? process.env.HAPPY_LOCAL_INIT_NO_RUNTIME ?? '').trim();
273
+ const initNoRuntime = initNoRuntimeRaw === '1' || initNoRuntimeRaw.toLowerCase() === 'true' || initNoRuntimeRaw.toLowerCase() === 'yes';
274
+ const forceRuntime = argv.includes('--force-runtime');
275
+ const skipRuntime = argv.includes('--no-runtime') || (initNoRuntime && !forceRuntime);
276
+ const installRuntime = !skipRuntime;
134
277
  if (installRuntime) {
135
- const pkg = JSON.parse(await readFile(join(cliRootDir, 'package.json'), 'utf-8'));
136
- const version = String(pkg.version ?? '').trim() || 'latest';
137
- const spec = version === '0.0.0' ? 'happy-stacks@latest' : `happy-stacks@${version}`;
278
+ const cliPkg = await readJsonIfExists(join(cliRootDir, 'package.json'));
279
+ const cliVersion = String(cliPkg?.version ?? '').trim() || 'latest';
280
+ const spec = cliVersion === '0.0.0' ? 'happy-stacks@latest' : `happy-stacks@${cliVersion}`;
138
281
 
139
- console.log(`[init] installing runtime into ${runtimeDir} (${spec})...`);
140
- let res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, spec], { stdio: 'inherit' });
141
- if (res.status !== 0) {
142
- // Pre-publish developer experience: if the package isn't on npm yet (E404),
143
- // fall back to installing the local checkout into the runtime prefix.
144
- console.log(`[init] runtime install failed; attempting local install from ${cliRootDir}...`);
145
- res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, cliRootDir], { stdio: 'inherit' });
282
+ const runtimePkgPath = join(runtimeDir, 'node_modules', 'happy-stacks', 'package.json');
283
+ const runtimePkg = await readJsonIfExists(runtimePkgPath);
284
+ const runtimeVersion = String(runtimePkg?.version ?? '').trim();
285
+ const sameVersionInstalled = Boolean(cliVersion && cliVersion !== '0.0.0' && runtimeVersion && runtimeVersion === cliVersion);
286
+
287
+ if (!forceRuntime && sameVersionInstalled) {
288
+ console.log(`[init] runtime already installed in ${runtimeDir} (happy-stacks@${runtimeVersion})`);
289
+ } else {
290
+ console.log(`[init] installing runtime into ${runtimeDir} (${spec})...`);
291
+ let res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, spec], { stdio: 'inherit' });
146
292
  if (res.status !== 0) {
147
- process.exit(res.status ?? 1);
293
+ // Pre-publish developer experience: if the package isn't on npm yet (E404),
294
+ // fall back to installing the local checkout into the runtime prefix.
295
+ console.log(`[init] runtime install failed; attempting local install from ${cliRootDir}...`);
296
+ res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, cliRootDir], { stdio: 'inherit' });
297
+ if (res.status !== 0) {
298
+ process.exit(res.status ?? 1);
299
+ }
148
300
  }
149
301
  }
150
302
  }
@@ -154,15 +306,58 @@ async function main() {
154
306
  const shim = [
155
307
  '#!/bin/bash',
156
308
  'set -euo pipefail',
309
+ 'CANONICAL_ENV="$HOME/.happy-stacks/.env"',
310
+ '',
311
+ '# Best-effort: if env vars are not exported (common under launchd/SwiftBar),',
312
+ '# read the stable pointer file at ~/.happy-stacks/.env to discover the real dirs.',
313
+ 'if [[ -f "$CANONICAL_ENV" ]]; then',
314
+ ' if [[ -z "${HAPPY_STACKS_HOME_DIR:-}" ]]; then',
315
+ ' HAPPY_STACKS_HOME_DIR="$(grep -E \'^HAPPY_STACKS_HOME_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_HOME_DIR=//\')" || true',
316
+ ' export HAPPY_STACKS_HOME_DIR',
317
+ ' fi',
318
+ ' if [[ -z "${HAPPY_STACKS_WORKSPACE_DIR:-}" ]]; then',
319
+ ' HAPPY_STACKS_WORKSPACE_DIR="$(grep -E \'^HAPPY_STACKS_WORKSPACE_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_WORKSPACE_DIR=//\')" || true',
320
+ ' export HAPPY_STACKS_WORKSPACE_DIR',
321
+ ' fi',
322
+ ' if [[ -z "${HAPPY_STACKS_RUNTIME_DIR:-}" ]]; then',
323
+ ' HAPPY_STACKS_RUNTIME_DIR="$(grep -E \'^HAPPY_STACKS_RUNTIME_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_RUNTIME_DIR=//\')" || true',
324
+ ' export HAPPY_STACKS_RUNTIME_DIR',
325
+ ' fi',
326
+ ' if [[ -z "${HAPPY_STACKS_NODE:-}" ]]; then',
327
+ ' HAPPY_STACKS_NODE="$(grep -E \'^HAPPY_STACKS_NODE=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_NODE=//\')" || true',
328
+ ' export HAPPY_STACKS_NODE',
329
+ ' fi',
330
+ ' if [[ -z "${HAPPY_STACKS_CLI_ROOT_DIR:-}" ]]; then',
331
+ ' HAPPY_STACKS_CLI_ROOT_DIR="$(grep -E \'^HAPPY_STACKS_CLI_ROOT_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_CLI_ROOT_DIR=//\')" || true',
332
+ ' export HAPPY_STACKS_CLI_ROOT_DIR',
333
+ ' fi',
334
+ 'fi',
335
+ '',
157
336
  'HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"',
158
337
  'ENV_FILE="$HOME_DIR/.env"',
159
- 'NODE_BIN=""',
160
- 'if [[ -f "$ENV_FILE" ]]; then',
338
+ 'WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-$HOME_DIR/workspace}"',
339
+ 'if [[ -d "$WORKDIR" ]]; then',
340
+ ' cd "$WORKDIR"',
341
+ 'else',
342
+ ' cd "$HOME"',
343
+ 'fi',
344
+ 'NODE_BIN="${HAPPY_STACKS_NODE:-}"',
345
+ 'if [[ -z "$NODE_BIN" && -f "$ENV_FILE" ]]; then',
161
346
  ' NODE_BIN="$(grep -E \'^HAPPY_STACKS_NODE=\' "$ENV_FILE" | head -n 1 | sed \'s/^HAPPY_STACKS_NODE=//\')"',
162
347
  'fi',
163
348
  'if [[ -z "$NODE_BIN" ]]; then',
164
349
  ' NODE_BIN="$(command -v node 2>/dev/null || true)"',
165
350
  'fi',
351
+ 'CLI_ROOT_DIR="${HAPPY_STACKS_CLI_ROOT_DIR:-}"',
352
+ 'if [[ -z "$CLI_ROOT_DIR" && -f "$ENV_FILE" ]]; then',
353
+ ' CLI_ROOT_DIR="$(grep -E \'^HAPPY_STACKS_CLI_ROOT_DIR=\' "$ENV_FILE" | head -n 1 | sed \'s/^HAPPY_STACKS_CLI_ROOT_DIR=//\')" || true',
354
+ 'fi',
355
+ 'if [[ -n "$CLI_ROOT_DIR" ]]; then',
356
+ ' CLI_ENTRY="$CLI_ROOT_DIR/bin/happys.mjs"',
357
+ ' if [[ -f "$CLI_ENTRY" ]]; then',
358
+ ' exec "$NODE_BIN" "$CLI_ENTRY" "$@"',
359
+ ' fi',
360
+ 'fi',
166
361
  'RUNTIME_DIR="${HAPPY_STACKS_RUNTIME_DIR:-$HOME_DIR/runtime}"',
167
362
  'ENTRY="$RUNTIME_DIR/node_modules/happy-stacks/bin/happys.mjs"',
168
363
  'if [[ -f "$ENTRY" ]]; then',
@@ -202,11 +397,15 @@ async function main() {
202
397
 
203
398
  const wantBootstrap = !argv.includes('--no-bootstrap');
204
399
  const isTty = process.stdout.isTTY && process.stdin.isTTY;
205
- const shouldBootstrap = wantBootstrap;
400
+ const alreadyBootstrapped = isWorkspaceBootstrapped(workspaceDir);
401
+ const bootstrapExplicit = bootstrapArgs.length > 0;
402
+ const shouldBootstrap = wantBootstrap && (bootstrapExplicit || !alreadyBootstrapped);
206
403
 
207
404
  if (shouldBootstrap) {
208
405
  const nextArgs = [...bootstrapArgs];
209
- if (isTty && !nextArgs.includes('--interactive') && !nextArgs.includes('-i')) {
406
+ // Only auto-enable the interactive wizard when init is driving bootstrap with no explicit args.
407
+ // If users pass args after `--`, we assume they know what they want and avoid injecting prompts.
408
+ if (!bootstrapExplicit && isTty && !nextArgs.includes('--interactive') && !nextArgs.includes('-i')) {
210
409
  nextArgs.unshift('--interactive');
211
410
  }
212
411
  console.log('[init] running bootstrap...');
@@ -221,6 +420,12 @@ async function main() {
221
420
  return;
222
421
  }
223
422
 
423
+ if (wantBootstrap && alreadyBootstrapped && !bootstrapExplicit) {
424
+ console.log('[init] bootstrap: already set up; skipping');
425
+ console.log('[init] tip: to re-run setup: happys bootstrap --interactive');
426
+ console.log('');
427
+ }
428
+
224
429
  console.log('[init] next steps:');
225
430
  console.log(` export PATH=\"${homeDir}/bin:$PATH\"`);
226
431
  console.log(' happys bootstrap --interactive');
@@ -230,3 +435,4 @@ main().catch((err) => {
230
435
  console.error('[init] failed:', err);
231
436
  process.exit(1);
232
437
  });
438
+
@@ -0,0 +1,292 @@
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/args.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/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
+
15
+ function usage() {
16
+ return [
17
+ '[migrate] usage:',
18
+ ' happys migrate light-to-server --from-stack=<name> --to-stack=<name> [--include-files] [--force] [--json]',
19
+ '',
20
+ 'Notes:',
21
+ '- This migrates chat data from happy-server-light (SQLite) to happy-server (Postgres).',
22
+ '- It preserves IDs, so existing session URLs keep working on the new server.',
23
+ '- If --include-files is set, it mirrors server-light local files into Minio (S3) in the target stack.',
24
+ ].join('\n');
25
+ }
26
+
27
+ async function readEnvObject(envPath) {
28
+ try {
29
+ const raw = await readFile(envPath, 'utf-8');
30
+ return Object.fromEntries(parseDotenv(raw).entries());
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function getEnvValue(env, key) {
37
+ return (env?.[key] ?? '').toString().trim();
38
+ }
39
+
40
+ function parseFileDatabaseUrl(url) {
41
+ const raw = String(url ?? '').trim();
42
+ if (!raw) return null;
43
+ if (raw.startsWith('file:')) {
44
+ const path = raw.slice('file:'.length);
45
+ return { url: raw, path };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ async function ensureTargetSecretMatchesSource({ sourceSecretPath, targetSecretPath }) {
51
+ try {
52
+ const src = (await readFile(sourceSecretPath, 'utf-8')).trim();
53
+ if (!src) return null;
54
+ await mkdir(join(targetSecretPath, '..'), { recursive: true }).catch(() => {});
55
+ const { rename, writeFile } = await import('node:fs/promises');
56
+ // Write with a trailing newline, via atomic replace.
57
+ const tmp = join(join(targetSecretPath, '..'), `.handy-master-secret.${Date.now()}.tmp`);
58
+ await writeFile(tmp, src + '\n', { encoding: 'utf-8', mode: 0o600 });
59
+ await rename(tmp, targetSecretPath);
60
+ return src;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ async function importPrismaClientFrom(dir) {
67
+ const req = createRequire(import.meta.url);
68
+ const resolved = req.resolve('@prisma/client', { paths: [dir] });
69
+ // eslint-disable-next-line import/no-dynamic-require
70
+ const mod = req(resolved);
71
+ return mod.PrismaClient;
72
+ }
73
+
74
+ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles, force, json }) {
75
+ const from = resolveStackEnvPath(fromStack);
76
+ const to = resolveStackEnvPath(toStack);
77
+
78
+ const fromEnv = await readEnvObject(from.envPath);
79
+ const toEnv = await readEnvObject(to.envPath);
80
+
81
+ const fromFlavor = getEnvValue(fromEnv, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(fromEnv, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
82
+ const toFlavor = getEnvValue(toEnv, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(toEnv, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
83
+
84
+ if (fromFlavor !== 'happy-server-light') {
85
+ throw new Error(`[migrate] from-stack must use happy-server-light (got: ${fromFlavor})`);
86
+ }
87
+ if (toFlavor !== 'happy-server') {
88
+ throw new Error(`[migrate] to-stack must use happy-server (got: ${toFlavor})`);
89
+ }
90
+
91
+ const fromDataDir = getEnvValue(fromEnv, 'HAPPY_SERVER_LIGHT_DATA_DIR') || join(from.baseDir, 'server-light');
92
+ const fromFilesDir = getEnvValue(fromEnv, 'HAPPY_SERVER_LIGHT_FILES_DIR') || join(fromDataDir, 'files');
93
+ const fromDbUrl = getEnvValue(fromEnv, 'DATABASE_URL') || `file:${join(fromDataDir, 'happy-server-light.sqlite')}`;
94
+ const fromParsed = parseFileDatabaseUrl(fromDbUrl);
95
+ if (!fromParsed?.path) {
96
+ throw new Error(`[migrate] from-stack DATABASE_URL must be file:... (got: ${fromDbUrl})`);
97
+ }
98
+
99
+ const toPortRaw = getEnvValue(toEnv, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(toEnv, 'HAPPY_LOCAL_SERVER_PORT');
100
+ const toPort = toPortRaw ? Number(toPortRaw) : NaN;
101
+ if (!Number.isFinite(toPort) || toPort <= 0) {
102
+ throw new Error(`[migrate] to-stack is missing a valid server port (HAPPY_STACKS_SERVER_PORT)`);
103
+ }
104
+
105
+ // Ensure target secret is the same as source so auth tokens remain valid after migration.
106
+ const sourceSecretPath = join(fromDataDir, 'handy-master-secret.txt');
107
+ const targetSecretPath = getEnvValue(toEnv, 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE') || join(to.baseDir, 'happy-server', 'handy-master-secret.txt');
108
+ await ensureTargetSecretMatchesSource({ sourceSecretPath, targetSecretPath });
109
+ await ensureEnvFileUpdated({
110
+ envPath: to.envPath,
111
+ updates: [{ key: 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE', value: targetSecretPath }],
112
+ });
113
+
114
+ // Bring up infra and ensure env vars are present.
115
+ const infra = await ensureHappyServerManagedInfra({
116
+ stackName: toStack,
117
+ baseDir: to.baseDir,
118
+ serverPort: toPort,
119
+ publicServerUrl: `http://127.0.0.1:${toPort}`,
120
+ envPath: to.envPath,
121
+ env: process.env,
122
+ });
123
+ await applyHappyServerMigrations({ serverDir: fullDir, env: { ...process.env, ...infra.env } });
124
+
125
+ // Resolve component dirs (prefer stack-pinned dirs).
126
+ const lightDir = getEnvValue(fromEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT') || getEnvValue(fromEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT');
127
+ const fullDir = getEnvValue(toEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER') || getEnvValue(toEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER');
128
+ if (!lightDir || !fullDir) {
129
+ 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)');
130
+ }
131
+
132
+ await ensureDepsInstalled(lightDir, 'happy-server-light');
133
+ await ensureDepsInstalled(fullDir, 'happy-server');
134
+
135
+ // Copy sqlite DB to a snapshot so migration is consistent even if the source server is running.
136
+ const snapshotDir = join(to.baseDir, 'migrations');
137
+ await mkdir(snapshotDir, { recursive: true });
138
+ const snapshotPath = join(snapshotDir, `happy-server-light.${basename(fromParsed.path)}.${Date.now()}.sqlite`);
139
+ await copyFile(fromParsed.path, snapshotPath);
140
+ const snapshotDbUrl = `file:${snapshotPath}`;
141
+
142
+ const SourcePrismaClient = await importPrismaClientFrom(lightDir);
143
+ const TargetPrismaClient = await importPrismaClientFrom(fullDir);
144
+
145
+ const sourceDb = new SourcePrismaClient({ datasources: { db: { url: snapshotDbUrl } } });
146
+ const targetDb = new TargetPrismaClient({ datasources: { db: { url: infra.env.DATABASE_URL } } });
147
+
148
+ try {
149
+ // Fail-fast unless target is empty (keeps this safe).
150
+ const existingSessions = await targetDb.session.count();
151
+ const existingMessages = await targetDb.sessionMessage.count();
152
+ if (!force && (existingSessions > 0 || existingMessages > 0)) {
153
+ throw new Error(
154
+ `[migrate] target database is not empty (sessions=${existingSessions}, messages=${existingMessages}).\n` +
155
+ `Pass --force to attempt a merge (skipDuplicates), or migrate into a fresh stack.`
156
+ );
157
+ }
158
+
159
+ // Core entities
160
+ const accounts = await sourceDb.account.findMany();
161
+ if (accounts.length) {
162
+ await targetDb.account.createMany({ data: accounts, skipDuplicates: true });
163
+ }
164
+
165
+ const machines = await sourceDb.machine.findMany();
166
+ if (machines.length) {
167
+ await targetDb.machine.createMany({ data: machines, skipDuplicates: true });
168
+ }
169
+
170
+ const accessKeys = await sourceDb.accessKey.findMany();
171
+ if (accessKeys.length) {
172
+ await targetDb.accessKey.createMany({ data: accessKeys, skipDuplicates: true });
173
+ }
174
+
175
+ const sessions = await sourceDb.session.findMany();
176
+ if (sessions.length) {
177
+ await targetDb.session.createMany({ data: sessions, skipDuplicates: true });
178
+ }
179
+
180
+ // Messages: stream in batches to avoid high memory.
181
+ let migrated = 0;
182
+ const batchSize = 1000;
183
+ let cursor = null;
184
+ while (true) {
185
+ // eslint-disable-next-line no-await-in-loop
186
+ const page = await sourceDb.sessionMessage.findMany({
187
+ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
188
+ orderBy: { id: 'asc' },
189
+ take: batchSize,
190
+ });
191
+ if (!page.length) break;
192
+ cursor = page[page.length - 1].id;
193
+ // eslint-disable-next-line no-await-in-loop
194
+ await targetDb.sessionMessage.createMany({ data: page, skipDuplicates: true });
195
+ migrated += page.length;
196
+ // eslint-disable-next-line no-console
197
+ if (!json && migrated % (batchSize * 20) === 0) console.log(`[migrate] migrated ${migrated} messages...`);
198
+ }
199
+
200
+ // Pending queue (small)
201
+ const pending = await sourceDb.sessionPendingMessage.findMany();
202
+ if (pending.length) {
203
+ await targetDb.sessionPendingMessage.createMany({ data: pending, skipDuplicates: true });
204
+ }
205
+
206
+ if (includeFiles) {
207
+ // Mirror server-light local files (public/*) into Minio bucket root.
208
+ // This assumes server-light stored public files under HAPPY_SERVER_LIGHT_FILES_DIR/public/...
209
+ // (Matches happy-server Minio object keys).
210
+ const { composePath, projectName } = infra;
211
+ await runCapture('docker', [
212
+ 'compose',
213
+ '-f',
214
+ composePath,
215
+ '-p',
216
+ projectName,
217
+ 'run',
218
+ '--rm',
219
+ '-T',
220
+ '-v',
221
+ `${fromFilesDir}:/src:ro`,
222
+ 'minio-init',
223
+ 'sh',
224
+ '-lc',
225
+ [
226
+ `mc alias set local http://minio:9000 ${infra.env.S3_ACCESS_KEY} ${infra.env.S3_SECRET_KEY}`,
227
+ `mc mirror --overwrite /src local/${infra.env.S3_BUCKET}`,
228
+ ].join(' && '),
229
+ ]);
230
+ }
231
+
232
+ printResult({
233
+ json,
234
+ data: {
235
+ ok: true,
236
+ fromStack,
237
+ toStack,
238
+ snapshotPath,
239
+ migrated: { accounts: accounts.length, sessions: sessions.length, messages: migrated, machines: machines.length, accessKeys: accessKeys.length },
240
+ filesMirrored: Boolean(includeFiles),
241
+ },
242
+ text: [
243
+ `[migrate] ok`,
244
+ `[migrate] from: ${fromStack} (${fromFlavor})`,
245
+ `[migrate] to: ${toStack} (${toFlavor})`,
246
+ `[migrate] sqlite snapshot: ${snapshotPath}`,
247
+ `[migrate] messages: ${migrated}`,
248
+ includeFiles ? `[migrate] files: mirrored from ${fromFilesDir} -> minio bucket ${infra.env.S3_BUCKET}` : `[migrate] files: skipped`,
249
+ ].join('\n'),
250
+ });
251
+ } finally {
252
+ await sourceDb.$disconnect().catch(() => {});
253
+ await targetDb.$disconnect().catch(() => {});
254
+ }
255
+ }
256
+
257
+ async function main() {
258
+ const argv = process.argv.slice(2);
259
+ const { flags, kv } = parseArgs(argv);
260
+ const json = wantsJson(argv, { flags });
261
+ if (wantsHelp(argv, { flags })) {
262
+ printResult({ json, data: { ok: true }, text: usage() });
263
+ return;
264
+ }
265
+
266
+ const cmd = argv.find((a) => !a.startsWith('--')) ?? '';
267
+ if (!cmd) {
268
+ throw new Error(usage());
269
+ }
270
+
271
+ if (cmd !== 'light-to-server') {
272
+ throw new Error(`[migrate] unknown subcommand: ${cmd}\n\n${usage()}`);
273
+ }
274
+
275
+ const fromStack = (kv.get('--from-stack') ?? 'main').trim();
276
+ const toStack = (kv.get('--to-stack') ?? '').trim();
277
+ const includeFiles = flags.has('--include-files') || (kv.get('--include-files') ?? '').trim() === '1';
278
+ const force = flags.has('--force');
279
+ if (!toStack) {
280
+ throw new Error('[migrate] --to-stack is required');
281
+ }
282
+
283
+ const rootDir = (await import('./utils/paths.mjs')).getRootDir(import.meta.url);
284
+ await migrateLightToServer({ rootDir, fromStack, toStack, includeFiles, force, json });
285
+ }
286
+
287
+ main().catch((err) => {
288
+ // eslint-disable-next-line no-console
289
+ console.error(err);
290
+ process.exit(1);
291
+ });
292
+