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.
- package/README.md +130 -74
- package/bin/happys.mjs +140 -9
- package/docs/edison.md +381 -0
- package/docs/happy-development.md +733 -0
- package/docs/menubar.md +54 -0
- package/docs/paths-and-env.md +141 -0
- package/docs/server-flavors.md +61 -2
- package/docs/stacks.md +55 -4
- package/extras/swiftbar/auth-login.sh +10 -7
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +175 -83
- package/extras/swiftbar/happys-term.sh +128 -0
- package/extras/swiftbar/happys.sh +35 -0
- package/extras/swiftbar/install.sh +99 -13
- package/extras/swiftbar/lib/git.sh +309 -1
- package/extras/swiftbar/lib/icons.sh +2 -2
- package/extras/swiftbar/lib/render.sh +279 -132
- package/extras/swiftbar/lib/system.sh +64 -10
- package/extras/swiftbar/lib/utils.sh +469 -10
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +4 -14
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +19 -10
- package/extras/swiftbar/wt-pr.sh +10 -3
- package/package.json +2 -1
- package/scripts/auth.mjs +833 -14
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +200 -23
- package/scripts/dev.mjs +230 -57
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +275 -46
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +302 -0
- package/scripts/mobile.mjs +59 -21
- package/scripts/run.mjs +222 -43
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +190 -38
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +2273 -92
- package/scripts/stop.mjs +160 -0
- package/scripts/tailscale.mjs +164 -23
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +21 -13
- package/scripts/utils/auth_files.mjs +58 -0
- package/scripts/utils/auth_login_ux.mjs +76 -0
- package/scripts/utils/auth_sources.mjs +12 -0
- package/scripts/utils/browser.mjs +22 -0
- package/scripts/utils/canonical_home.mjs +20 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +13 -1
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/dev_daemon.mjs +104 -0
- package/scripts/utils/dev_expo_web.mjs +112 -0
- package/scripts/utils/dev_server.mjs +183 -0
- package/scripts/utils/env.mjs +94 -23
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +96 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +484 -0
- package/scripts/utils/localhost_host.mjs +17 -0
- package/scripts/utils/ownership.mjs +135 -0
- package/scripts/utils/paths.mjs +5 -2
- package/scripts/utils/pm.mjs +132 -22
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +75 -7
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +61 -0
- package/scripts/utils/server_port.mjs +9 -0
- package/scripts/utils/server_urls.mjs +54 -0
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stack_startup.mjs +208 -0
- package/scripts/utils/stack_stop.mjs +255 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +135 -15
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/menubar.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
|
package/scripts/mobile.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import './utils/env.mjs';
|
|
2
|
-
import { parseArgs } from './utils/args.mjs';
|
|
3
|
-
import {
|
|
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
|
|
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 = ['
|
|
177
|
+
const prebuildArgs = ['prebuild', '--no-install', '--platform', platform];
|
|
159
178
|
if (shouldClean) {
|
|
160
179
|
prebuildArgs.push('--clean');
|
|
161
180
|
}
|
|
162
|
-
await
|
|
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 = ['
|
|
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
|
|
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
|
|
284
|
-
if (
|
|
285
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
'
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
}
|