happy-stacks 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -40
- package/bin/happys.mjs +158 -16
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
- package/scripts/utils/dev/expo_web.mjs +0 -112
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { prompt, promptWorktreeSource } from '../cli/wizard.mjs';
|
|
4
|
+
import { coerceHappyMonorepoRootFromPath, getComponentsDir } from '../paths/paths.mjs';
|
|
5
|
+
import { resolveComponentSpecToDir } from '../git/worktrees.mjs';
|
|
6
|
+
|
|
7
|
+
function wantsNo(raw) {
|
|
8
|
+
const v = String(raw ?? '').trim().toLowerCase();
|
|
9
|
+
return v === 'n' || v === 'no' || v === '0' || v === 'false';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveHappySpecDir({ rootDir, spec }) {
|
|
13
|
+
if (!spec) return '';
|
|
14
|
+
if (spec === 'default' || spec === 'main') {
|
|
15
|
+
return join(getComponentsDir(rootDir), 'happy');
|
|
16
|
+
}
|
|
17
|
+
if (typeof spec === 'string') {
|
|
18
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy', spec });
|
|
19
|
+
return dir ? resolve(rootDir, dir) : '';
|
|
20
|
+
}
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function interactiveNew({ rootDir, rl, defaults, deps = {} }) {
|
|
25
|
+
const promptFn = deps.prompt ?? prompt;
|
|
26
|
+
const promptWorktreeSourceFn = deps.promptWorktreeSource ?? promptWorktreeSource;
|
|
27
|
+
|
|
28
|
+
const out = { ...defaults };
|
|
29
|
+
|
|
30
|
+
if (!out.stackName) {
|
|
31
|
+
out.stackName = (await rl.question('Stack name: ')).trim();
|
|
32
|
+
}
|
|
33
|
+
if (!out.stackName) {
|
|
34
|
+
throw new Error('[stack] stack name is required');
|
|
35
|
+
}
|
|
36
|
+
if (out.stackName === 'main') {
|
|
37
|
+
throw new Error('[stack] stack name "main" is reserved (use the default stack without creating it)');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!out.serverComponent) {
|
|
41
|
+
const server = (await rl.question('Server component [happy-server-light|happy-server] (default: happy-server-light): ')).trim();
|
|
42
|
+
out.serverComponent = server || 'happy-server-light';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!out.port) {
|
|
46
|
+
const want = (await rl.question('Port (empty = ephemeral): ')).trim();
|
|
47
|
+
out.port = want ? Number(want) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!out.createRemote) {
|
|
51
|
+
out.createRemote = await promptFn(rl, 'Git remote for creating new worktrees (default: upstream): ', { defaultValue: 'upstream' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (out.components.happy == null) {
|
|
55
|
+
out.components.happy = await promptWorktreeSourceFn({
|
|
56
|
+
rl,
|
|
57
|
+
rootDir,
|
|
58
|
+
component: 'happy',
|
|
59
|
+
stackName: out.stackName,
|
|
60
|
+
createRemote: out.createRemote,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const happyIsCreate = Boolean(out.components.happy && typeof out.components.happy === 'object' && out.components.happy.create);
|
|
65
|
+
const happyMonoRoot = coerceHappyMonorepoRootFromPath(resolveHappySpecDir({ rootDir, spec: out.components.happy }));
|
|
66
|
+
const canDeriveMonorepoGroup = Boolean(happyMonoRoot) || happyIsCreate;
|
|
67
|
+
let deriveMonorepoGroup = false;
|
|
68
|
+
|
|
69
|
+
if (canDeriveMonorepoGroup) {
|
|
70
|
+
const ans = await promptFn(rl, 'Detected happy monorepo checkout. Derive happy-cli + happy-server from it? [Y/n]: ', {
|
|
71
|
+
defaultValue: 'y',
|
|
72
|
+
});
|
|
73
|
+
deriveMonorepoGroup = !wantsNo(ans);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (deriveMonorepoGroup) {
|
|
77
|
+
out.components['happy-cli'] = null;
|
|
78
|
+
out.components['happy-server'] = null;
|
|
79
|
+
// In monorepo mode, happy-server-light is derived when supported by the monorepo server checkout.
|
|
80
|
+
// If not supported, the stack env will keep the default separate happy-server-light checkout.
|
|
81
|
+
out.components['happy-server-light'] = null;
|
|
82
|
+
} else if (out.components['happy-cli'] == null) {
|
|
83
|
+
out.components['happy-cli'] = await promptWorktreeSourceFn({
|
|
84
|
+
rl,
|
|
85
|
+
rootDir,
|
|
86
|
+
component: 'happy-cli',
|
|
87
|
+
stackName: out.stackName,
|
|
88
|
+
createRemote: out.createRemote,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
93
|
+
if (serverComponent === 'happy-server-light' && deriveMonorepoGroup) {
|
|
94
|
+
out.components['happy-server-light'] = null;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
if (serverComponent === 'happy-server' && deriveMonorepoGroup) {
|
|
98
|
+
out.components['happy-server'] = null;
|
|
99
|
+
} else if (out.components[serverComponent] == null) {
|
|
100
|
+
out.components[serverComponent] = await promptWorktreeSourceFn({
|
|
101
|
+
rl,
|
|
102
|
+
rootDir,
|
|
103
|
+
component: serverComponent,
|
|
104
|
+
stackName: out.stackName,
|
|
105
|
+
createRemote: out.createRemote,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults, deps = {} }) {
|
|
113
|
+
const promptFn = deps.prompt ?? prompt;
|
|
114
|
+
const promptWorktreeSourceFn = deps.promptWorktreeSource ?? promptWorktreeSource;
|
|
115
|
+
|
|
116
|
+
const out = { ...defaults, stackName };
|
|
117
|
+
|
|
118
|
+
const currentServer = existingEnv.HAPPY_STACKS_SERVER_COMPONENT ?? existingEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '';
|
|
119
|
+
const server = await promptFn(rl, `Server component [happy-server-light|happy-server] (default: ${currentServer || 'happy-server-light'}): `, {
|
|
120
|
+
defaultValue: currentServer || 'happy-server-light',
|
|
121
|
+
});
|
|
122
|
+
out.serverComponent = server || 'happy-server-light';
|
|
123
|
+
|
|
124
|
+
const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
|
|
125
|
+
const wantPort = await promptFn(rl, `Port (empty = keep ${currentPort || 'ephemeral'}; type 'ephemeral' to unpin): `, { defaultValue: '' });
|
|
126
|
+
const wantTrimmed = wantPort.trim().toLowerCase();
|
|
127
|
+
out.port = wantTrimmed === 'ephemeral' ? null : wantPort ? Number(wantPort) : currentPort ? Number(currentPort) : null;
|
|
128
|
+
|
|
129
|
+
const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
|
|
130
|
+
out.createRemote = await promptFn(rl, `Git remote for creating new worktrees (default: ${currentRemote || 'upstream'}): `, {
|
|
131
|
+
defaultValue: currentRemote || 'upstream',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
out.components.happy = await promptWorktreeSourceFn({
|
|
135
|
+
rl,
|
|
136
|
+
rootDir,
|
|
137
|
+
component: 'happy',
|
|
138
|
+
stackName,
|
|
139
|
+
createRemote: out.createRemote,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const happyIsCreate = Boolean(out.components.happy && typeof out.components.happy === 'object' && out.components.happy.create);
|
|
143
|
+
const happyMonoRoot = coerceHappyMonorepoRootFromPath(resolveHappySpecDir({ rootDir, spec: out.components.happy }));
|
|
144
|
+
const canDeriveMonorepoGroup = Boolean(happyMonoRoot) || happyIsCreate;
|
|
145
|
+
let deriveMonorepoGroup = false;
|
|
146
|
+
if (canDeriveMonorepoGroup) {
|
|
147
|
+
const ans = await promptFn(rl, 'Detected happy monorepo checkout. Derive happy-cli + happy-server from it? [Y/n]: ', {
|
|
148
|
+
defaultValue: 'y',
|
|
149
|
+
});
|
|
150
|
+
deriveMonorepoGroup = !wantsNo(ans);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (deriveMonorepoGroup) {
|
|
154
|
+
out.components['happy-cli'] = null;
|
|
155
|
+
out.components['happy-server'] = null;
|
|
156
|
+
out.components['happy-server-light'] = null;
|
|
157
|
+
} else if (out.components['happy-cli'] == null) {
|
|
158
|
+
out.components['happy-cli'] = await promptWorktreeSourceFn({
|
|
159
|
+
rl,
|
|
160
|
+
rootDir,
|
|
161
|
+
component: 'happy-cli',
|
|
162
|
+
stackName,
|
|
163
|
+
createRemote: out.createRemote,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
168
|
+
if (serverComponent === 'happy-server-light' && deriveMonorepoGroup) {
|
|
169
|
+
out.components['happy-server-light'] = null;
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
if (serverComponent === 'happy-server' && deriveMonorepoGroup) {
|
|
173
|
+
out.components['happy-server'] = null;
|
|
174
|
+
} else {
|
|
175
|
+
out.components[serverComponent] = await promptWorktreeSourceFn({
|
|
176
|
+
rl,
|
|
177
|
+
rootDir,
|
|
178
|
+
component: serverComponent,
|
|
179
|
+
stackName,
|
|
180
|
+
createRemote: out.createRemote,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { parseGithubPullRequest } from '../git/refs.mjs';
|
|
2
|
+
import { sanitizeStackName } from './names.mjs';
|
|
3
|
+
|
|
4
|
+
export function inferPrStackBaseName({ happy, happyCli, server, serverLight, fallback = 'pr' }) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
const hn = parseGithubPullRequest(happy)?.number ?? null;
|
|
7
|
+
const cn = parseGithubPullRequest(happyCli)?.number ?? null;
|
|
8
|
+
const sn = parseGithubPullRequest(server)?.number ?? null;
|
|
9
|
+
const sln = parseGithubPullRequest(serverLight)?.number ?? null;
|
|
10
|
+
if (hn) parts.push(`happy${hn}`);
|
|
11
|
+
if (cn) parts.push(`cli${cn}`);
|
|
12
|
+
if (sn) parts.push(`server${sn}`);
|
|
13
|
+
if (sln) parts.push(`light${sln}`);
|
|
14
|
+
return sanitizeStackName(parts.length ? `pr-${parts.join('-')}` : fallback, { fallback, maxLen: 64 });
|
|
15
|
+
}
|
|
16
|
+
|
|
@@ -51,7 +51,7 @@ export async function updateStackRuntimeStateFile(statePath, patch) {
|
|
|
51
51
|
return next;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports } = {}) {
|
|
54
|
+
export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports, ...rest } = {}) {
|
|
55
55
|
const now = new Date().toISOString();
|
|
56
56
|
const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
|
|
57
57
|
const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
|
|
@@ -64,6 +64,7 @@ export async function recordStackRuntimeStart(statePath, { stackName, script, ep
|
|
|
64
64
|
ports: ports ?? {},
|
|
65
65
|
startedAt,
|
|
66
66
|
updatedAt: now,
|
|
67
|
+
...(rest ?? {}),
|
|
67
68
|
});
|
|
68
69
|
await writeStackRuntimeStateFile(statePath, next);
|
|
69
70
|
return next;
|
|
@@ -1,18 +1,53 @@
|
|
|
1
1
|
import { runCapture } from '../proc/proc.mjs';
|
|
2
2
|
import { ensureDepsInstalled, pmExecBin } from '../proc/pm.mjs';
|
|
3
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
|
|
3
4
|
import { existsSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
6
|
+
import { mkdir } from 'node:fs/promises';
|
|
7
|
+
import { resolvePrismaClientImportForServerComponent, resolveServerLightPrismaMigrateDeployArgs, resolveServerLightPrismaSchemaArgs } from '../server/flavor_scripts.mjs';
|
|
5
8
|
|
|
6
9
|
function looksLikeMissingTableError(msg) {
|
|
7
10
|
const s = String(msg ?? '').toLowerCase();
|
|
8
11
|
return s.includes('does not exist') || s.includes('no such table');
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
function looksLikeAlreadyExistsError(msg) {
|
|
15
|
+
const s = String(msg ?? '').toLowerCase();
|
|
16
|
+
return s.includes('already exists') || s.includes('duplicate') || s.includes('constraint failed');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function looksLikeMissingGeneratedSqliteClientError(err) {
|
|
20
|
+
const code = err && typeof err === 'object' ? err.code : '';
|
|
21
|
+
if (code !== 'ERR_MODULE_NOT_FOUND') return false;
|
|
22
|
+
const msg = err instanceof Error ? err.message : String(err ?? '');
|
|
23
|
+
return msg.includes('/generated/sqlite-client/') || msg.includes('\\generated\\sqlite-client\\');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function findSqliteBaselineMigrationDir({ serverDir }) {
|
|
27
|
+
try {
|
|
28
|
+
// Unified monorepo server-light migrations live under prisma/sqlite/migrations.
|
|
29
|
+
// For legacy schema.sqlite.prisma setups, migrations use the default prisma/migrations folder.
|
|
30
|
+
const migrationsDir = existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
|
|
31
|
+
? join(serverDir, 'prisma', 'sqlite', 'migrations')
|
|
32
|
+
: join(serverDir, 'prisma', 'migrations');
|
|
33
|
+
const { readdir } = await import('node:fs/promises');
|
|
34
|
+
const entries = await readdir(migrationsDir, { withFileTypes: true });
|
|
35
|
+
const dirs = entries
|
|
36
|
+
.filter((e) => e.isDirectory())
|
|
37
|
+
.map((e) => e.name)
|
|
38
|
+
.sort();
|
|
39
|
+
return dirs[0] || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function probeAccountCount({ serverComponentName, serverDir, env }) {
|
|
46
|
+
const clientImport = resolvePrismaClientImportForServerComponent({ serverComponentName, serverDir });
|
|
12
47
|
const probe = `
|
|
13
48
|
let db;
|
|
14
49
|
try {
|
|
15
|
-
const { PrismaClient } = await import(
|
|
50
|
+
const { PrismaClient } = await import(${JSON.stringify(clientImport)});
|
|
16
51
|
db = new PrismaClient();
|
|
17
52
|
const accountCount = await db.account.count();
|
|
18
53
|
console.log(JSON.stringify({ accountCount }));
|
|
@@ -47,6 +82,12 @@ async function probeAccountCount({ serverDir, env }) {
|
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
export function resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive }) {
|
|
85
|
+
// Sandboxes should be isolated by default.
|
|
86
|
+
// Auto auth seeding can copy credentials/account rows from another stack (global state),
|
|
87
|
+
// which breaks isolation and can confuse guided auth flows (setup-pr/review-pr).
|
|
88
|
+
if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
50
91
|
const raw = (env.HAPPY_STACKS_AUTO_AUTH_SEED ?? env.HAPPY_LOCAL_AUTO_AUTH_SEED ?? '').toString().trim();
|
|
51
92
|
if (raw) return raw !== '0';
|
|
52
93
|
|
|
@@ -83,27 +124,93 @@ export function resolveAuthSeedFromEnv(env) {
|
|
|
83
124
|
}
|
|
84
125
|
|
|
85
126
|
export async function ensureServerLightSchemaReady({ serverDir, env }) {
|
|
86
|
-
await ensureDepsInstalled(serverDir, 'happy-server-light');
|
|
127
|
+
await ensureDepsInstalled(serverDir, 'happy-server-light', { env });
|
|
87
128
|
|
|
129
|
+
const dataDir = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').toString().trim();
|
|
130
|
+
const filesDir = (env?.HAPPY_SERVER_LIGHT_FILES_DIR ?? '').toString().trim() || (dataDir ? join(dataDir, 'files') : '');
|
|
131
|
+
if (dataDir) {
|
|
132
|
+
try {
|
|
133
|
+
await mkdir(dataDir, { recursive: true });
|
|
134
|
+
} catch {
|
|
135
|
+
// best-effort
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (filesDir) {
|
|
139
|
+
try {
|
|
140
|
+
await mkdir(filesDir, { recursive: true });
|
|
141
|
+
} catch {
|
|
142
|
+
// best-effort
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const probe = async () => await probeAccountCount({ serverComponentName: 'happy-server-light', serverDir, env });
|
|
147
|
+
const schemaArgs = resolveServerLightPrismaSchemaArgs({ serverDir });
|
|
148
|
+
|
|
149
|
+
const isUnified = schemaArgs.length > 0;
|
|
150
|
+
|
|
151
|
+
// Unified server-light (monorepo): ensure deterministic migrations are applied (idempotent).
|
|
152
|
+
// Legacy server-light (single schema.prisma with db push): do NOT run `prisma migrate deploy`,
|
|
153
|
+
// because it commonly fails with P3005 when the DB was created by `prisma db push` and no migrations exist.
|
|
154
|
+
if (isUnified) {
|
|
155
|
+
try {
|
|
156
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
159
|
+
// If the SQLite DB was created before migrations existed (historical db push era),
|
|
160
|
+
// `migrate deploy` can fail because tables already exist. Best-effort: baseline-resolve
|
|
161
|
+
// the first migration, then retry deploy.
|
|
162
|
+
if (looksLikeAlreadyExistsError(msg)) {
|
|
163
|
+
const baseline = await findSqliteBaselineMigrationDir({ serverDir });
|
|
164
|
+
if (baseline) {
|
|
165
|
+
await pmExecBin({
|
|
166
|
+
dir: serverDir,
|
|
167
|
+
bin: 'prisma',
|
|
168
|
+
args: ['migrate', 'resolve', ...schemaArgs, '--applied', baseline],
|
|
169
|
+
env,
|
|
170
|
+
}).catch(() => {});
|
|
171
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
|
|
172
|
+
} else {
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
throw e;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2) Probe account count (used for auth seeding heuristics).
|
|
88
182
|
try {
|
|
89
|
-
const accountCount = await
|
|
90
|
-
return { ok: true,
|
|
183
|
+
const accountCount = await probe();
|
|
184
|
+
return { ok: true, migrated: isUnified, accountCount };
|
|
91
185
|
} catch (e) {
|
|
186
|
+
if (looksLikeMissingGeneratedSqliteClientError(e)) {
|
|
187
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['generate', ...schemaArgs], env });
|
|
188
|
+
const accountCount = await probe();
|
|
189
|
+
return { ok: true, migrated: isUnified, accountCount };
|
|
190
|
+
}
|
|
92
191
|
const msg = e instanceof Error ? e.message : String(e);
|
|
93
|
-
if (
|
|
94
|
-
|
|
192
|
+
if (looksLikeMissingTableError(msg)) {
|
|
193
|
+
if (isUnified) {
|
|
194
|
+
// Tables still missing after migrate deploy; fail closed with a clear error.
|
|
195
|
+
throw new Error(`[server-light] sqlite schema not ready after prisma migrate deploy (missing tables).`);
|
|
196
|
+
}
|
|
197
|
+
// Legacy server-light: schema is typically applied via `prisma db push` in the component's dev/start scripts.
|
|
198
|
+
// Best-effort: don't fail the whole stack startup just because we can't probe here.
|
|
199
|
+
return { ok: true, migrated: false, accountCount: 0 };
|
|
200
|
+
}
|
|
201
|
+
if (!isUnified) {
|
|
202
|
+
// Legacy server-light: probing is best-effort (don't make stack dev fail closed here).
|
|
203
|
+
return { ok: true, migrated: false, accountCount: 0 };
|
|
95
204
|
}
|
|
96
|
-
|
|
97
|
-
const accountCount = await probeAccountCount({ serverDir, env });
|
|
98
|
-
return { ok: true, pushed: true, accountCount };
|
|
205
|
+
throw e;
|
|
99
206
|
}
|
|
100
207
|
}
|
|
101
208
|
|
|
102
209
|
export async function ensureHappyServerSchemaReady({ serverDir, env }) {
|
|
103
|
-
await ensureDepsInstalled(serverDir, 'happy-server');
|
|
210
|
+
await ensureDepsInstalled(serverDir, 'happy-server', { env });
|
|
104
211
|
|
|
105
212
|
try {
|
|
106
|
-
const accountCount = await probeAccountCount({ serverDir, env });
|
|
213
|
+
const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
|
|
107
214
|
return { ok: true, migrated: false, accountCount };
|
|
108
215
|
} catch (e) {
|
|
109
216
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -112,7 +219,7 @@ export async function ensureHappyServerSchemaReady({ serverDir, env }) {
|
|
|
112
219
|
}
|
|
113
220
|
// If tables are missing, try migrations (safe for postgres). Then re-probe.
|
|
114
221
|
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
|
|
115
|
-
const accountCount = await probeAccountCount({ serverDir, env });
|
|
222
|
+
const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
|
|
116
223
|
return { ok: true, migrated: true, accountCount };
|
|
117
224
|
}
|
|
118
225
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
import { ensureServerLightSchemaReady } from './startup.mjs';
|
|
9
|
+
|
|
10
|
+
async function writeJson(path, obj) {
|
|
11
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('ensureServerLightSchemaReady creates stack sqlite data dirs before probing', async (t) => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-dirs-'));
|
|
16
|
+
t.after(async () => {
|
|
17
|
+
await rm(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const serverDir = join(root, 'server');
|
|
21
|
+
await mkdir(serverDir, { recursive: true });
|
|
22
|
+
await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
|
|
23
|
+
await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
24
|
+
await mkdir(join(serverDir, 'node_modules'), { recursive: true });
|
|
25
|
+
await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
|
|
26
|
+
|
|
27
|
+
await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
|
|
28
|
+
await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
29
|
+
|
|
30
|
+
// Provide the generated client so we don't need to run prisma generate in this test.
|
|
31
|
+
await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(serverDir, 'generated', 'sqlite-client', 'index.js'),
|
|
34
|
+
['export class PrismaClient {', ' constructor() { this.account = { count: async () => 0 }; }', ' async $disconnect() {}', '}'].join('\n') +
|
|
35
|
+
'\n',
|
|
36
|
+
'utf-8'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Minimal stub `yarn` so commandExists('yarn') succeeds.
|
|
40
|
+
const binDir = join(root, 'bin');
|
|
41
|
+
await mkdir(binDir, { recursive: true });
|
|
42
|
+
const yarnPath = join(binDir, 'yarn');
|
|
43
|
+
await writeFile(yarnPath, ['#!/usr/bin/env node', "console.log('1.22.22');"].join('\n') + '\n', 'utf-8');
|
|
44
|
+
await chmod(yarnPath, 0o755);
|
|
45
|
+
|
|
46
|
+
const dataDir = join(root, 'data');
|
|
47
|
+
const filesDir = join(dataDir, 'files');
|
|
48
|
+
const env = {
|
|
49
|
+
...process.env,
|
|
50
|
+
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
|
51
|
+
HAPPY_SERVER_LIGHT_DATA_DIR: dataDir,
|
|
52
|
+
HAPPY_SERVER_LIGHT_FILES_DIR: filesDir,
|
|
53
|
+
DATABASE_URL: `file:${join(dataDir, 'happy-server-light.sqlite')}`,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
assert.equal(existsSync(dataDir), false);
|
|
57
|
+
assert.equal(existsSync(filesDir), false);
|
|
58
|
+
|
|
59
|
+
await ensureServerLightSchemaReady({ serverDir, env });
|
|
60
|
+
|
|
61
|
+
assert.equal(existsSync(dataDir), true);
|
|
62
|
+
assert.equal(existsSync(filesDir), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile, chmod } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { ensureServerLightSchemaReady } from './startup.mjs';
|
|
8
|
+
|
|
9
|
+
async function writeJson(path, obj) {
|
|
10
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('ensureServerLightSchemaReady runs prisma generate when unified sqlite client is missing', async (t) => {
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-generate-'));
|
|
15
|
+
t.after(async () => {
|
|
16
|
+
await rm(root, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const serverDir = join(root, 'server');
|
|
20
|
+
await mkdir(serverDir, { recursive: true });
|
|
21
|
+
await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
|
|
22
|
+
await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
23
|
+
|
|
24
|
+
// Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
|
|
25
|
+
await mkdir(join(serverDir, 'node_modules'), { recursive: true });
|
|
26
|
+
await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
|
|
27
|
+
|
|
28
|
+
// Unified light detection + expected schema path.
|
|
29
|
+
await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
|
|
30
|
+
await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
31
|
+
|
|
32
|
+
// generated/sqlite-client exists, but the entrypoint is missing (this triggers ERR_MODULE_NOT_FOUND).
|
|
33
|
+
await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
|
|
34
|
+
|
|
35
|
+
// Provide a stub `yarn` in PATH so pmExecBin("prisma", ...) succeeds without real dependencies.
|
|
36
|
+
const binDir = join(root, 'bin');
|
|
37
|
+
await mkdir(binDir, { recursive: true });
|
|
38
|
+
const yarnPath = join(binDir, 'yarn');
|
|
39
|
+
await writeFile(
|
|
40
|
+
yarnPath,
|
|
41
|
+
[
|
|
42
|
+
'#!/usr/bin/env node',
|
|
43
|
+
"const { writeFileSync } = require('node:fs');",
|
|
44
|
+
"const { join } = require('node:path');",
|
|
45
|
+
'const cwd = process.cwd();',
|
|
46
|
+
'const out = join(cwd, "generated", "sqlite-client", "index.js");',
|
|
47
|
+
'const text = [',
|
|
48
|
+
" 'export class PrismaClient {',",
|
|
49
|
+
" ' constructor() { this.account = { count: async () => 0 }; }',",
|
|
50
|
+
" ' async $disconnect() {}',",
|
|
51
|
+
" '}',",
|
|
52
|
+
"].join('\\n') + '\\n';",
|
|
53
|
+
'writeFileSync(out, text, "utf-8");',
|
|
54
|
+
'process.exit(0);',
|
|
55
|
+
].join('\n') + '\n',
|
|
56
|
+
'utf-8'
|
|
57
|
+
);
|
|
58
|
+
await chmod(yarnPath, 0o755);
|
|
59
|
+
|
|
60
|
+
const oldPath = process.env.PATH;
|
|
61
|
+
try {
|
|
62
|
+
process.env.PATH = `${binDir}:${oldPath ?? ''}`;
|
|
63
|
+
const res = await ensureServerLightSchemaReady({ serverDir, env: process.env });
|
|
64
|
+
assert.equal(res.ok, true);
|
|
65
|
+
assert.equal(res.migrated, true);
|
|
66
|
+
assert.equal(res.accountCount, 0);
|
|
67
|
+
} finally {
|
|
68
|
+
process.env.PATH = oldPath;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
import { ensureServerLightSchemaReady } from './startup.mjs';
|
|
9
|
+
|
|
10
|
+
async function writeJson(path, obj) {
|
|
11
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('ensureServerLightSchemaReady does not run prisma migrate deploy for legacy happy-server-light checkouts', async (t) => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-legacy-'));
|
|
16
|
+
t.after(async () => {
|
|
17
|
+
await rm(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const serverDir = join(root, 'server');
|
|
21
|
+
await mkdir(serverDir, { recursive: true });
|
|
22
|
+
await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0', type: 'module' });
|
|
23
|
+
await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
24
|
+
|
|
25
|
+
// Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
|
|
26
|
+
await mkdir(join(serverDir, 'node_modules'), { recursive: true });
|
|
27
|
+
await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
|
|
28
|
+
|
|
29
|
+
// Legacy checkout: no prisma/sqlite/schema.prisma and no prisma/schema.sqlite.prisma.
|
|
30
|
+
// Provide a minimal node_modules @prisma/client so probeAccountCount can succeed.
|
|
31
|
+
await mkdir(join(serverDir, 'node_modules', '@prisma', 'client'), { recursive: true });
|
|
32
|
+
await writeJson(join(serverDir, 'node_modules', '@prisma', 'client', 'package.json'), {
|
|
33
|
+
name: '@prisma/client',
|
|
34
|
+
type: 'module',
|
|
35
|
+
main: './index.js',
|
|
36
|
+
});
|
|
37
|
+
await writeFile(
|
|
38
|
+
join(serverDir, 'node_modules', '@prisma', 'client', 'index.js'),
|
|
39
|
+
[
|
|
40
|
+
'export class PrismaClient {',
|
|
41
|
+
' constructor() { this.account = { count: async () => 0 }; }',
|
|
42
|
+
' async $disconnect() {}',
|
|
43
|
+
'}',
|
|
44
|
+
].join('\n') + '\n',
|
|
45
|
+
'utf-8'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const marker = join(root, 'called-prisma.txt');
|
|
49
|
+
|
|
50
|
+
// Provide a stub `yarn` so ensureYarnReady + pmExecBin are controllable.
|
|
51
|
+
const binDir = join(root, 'bin');
|
|
52
|
+
await mkdir(binDir, { recursive: true });
|
|
53
|
+
const yarnPath = join(binDir, 'yarn');
|
|
54
|
+
await writeFile(
|
|
55
|
+
yarnPath,
|
|
56
|
+
[
|
|
57
|
+
'#!/usr/bin/env node',
|
|
58
|
+
"const fs = require('node:fs');",
|
|
59
|
+
"const path = require('node:path');",
|
|
60
|
+
"const args = process.argv.slice(2);",
|
|
61
|
+
// ensureYarnReady calls: yarn --version
|
|
62
|
+
"if (args.includes('--version')) { console.log('1.22.22'); process.exit(0); }",
|
|
63
|
+
// pmExecBin calls: yarn run prisma ...
|
|
64
|
+
"if (args[0] === 'run' && args[1] === 'prisma') {",
|
|
65
|
+
` fs.writeFileSync(${JSON.stringify(marker)}, args.join(' ') + '\\n', 'utf-8');`,
|
|
66
|
+
' process.exit(0);',
|
|
67
|
+
'}',
|
|
68
|
+
"console.log('ok');",
|
|
69
|
+
'process.exit(0);',
|
|
70
|
+
].join('\n') + '\n',
|
|
71
|
+
'utf-8'
|
|
72
|
+
);
|
|
73
|
+
await chmod(yarnPath, 0o755);
|
|
74
|
+
|
|
75
|
+
const env = {
|
|
76
|
+
...process.env,
|
|
77
|
+
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
|
78
|
+
DATABASE_URL: `file:${join(root, 'happy-server-light.sqlite')}`,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const res = await ensureServerLightSchemaReady({ serverDir, env });
|
|
82
|
+
assert.equal(res.ok, true);
|
|
83
|
+
assert.equal(res.migrated, false);
|
|
84
|
+
assert.equal(res.accountCount, 0);
|
|
85
|
+
|
|
86
|
+
assert.equal(existsSync(marker), false, `expected no prisma migrate deploy call, but saw: ${marker}`);
|
|
87
|
+
});
|
|
88
|
+
|
|
@@ -126,6 +126,7 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
|
|
|
126
126
|
daemonSessionsStopped: null,
|
|
127
127
|
daemonStopped: false,
|
|
128
128
|
killedPorts: [],
|
|
129
|
+
expoDev: [],
|
|
129
130
|
uiDev: [],
|
|
130
131
|
mobile: [],
|
|
131
132
|
infra: null,
|
|
@@ -136,7 +137,13 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
|
|
|
136
137
|
const port = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
|
|
137
138
|
const backendPort = coercePort(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
|
|
138
139
|
const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
|
|
139
|
-
|
|
140
|
+
// IMPORTANT:
|
|
141
|
+
// When stopping a stack, always prefer the stack's pinned happy-cli checkout/worktree.
|
|
142
|
+
// Otherwise, PR stacks can accidentally run the base checkout's CLI bin, which may not be built
|
|
143
|
+
// (we intentionally skip building base checkouts in some sandbox PR flows).
|
|
144
|
+
const pinnedCliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim();
|
|
145
|
+
const cliDir = pinnedCliDir || getComponentDir(rootDir, 'happy-cli');
|
|
146
|
+
const cliBin = join(cliDir, 'bin', 'happy.mjs');
|
|
140
147
|
const envPath = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
|
|
141
148
|
|
|
142
149
|
// Preferred: stop stack-started processes (by PID) recorded in stack.runtime.json.
|
|
@@ -194,12 +201,16 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
|
|
|
194
201
|
}
|
|
195
202
|
|
|
196
203
|
try {
|
|
197
|
-
actions.
|
|
204
|
+
actions.expoDev = await stopExpoStateDir({ stackName, baseDir, kind: 'expo-dev', stateFileName: 'expo.state.json', envPath, json });
|
|
198
205
|
} catch (e) {
|
|
199
|
-
actions.errors.push({ step: 'expo-
|
|
206
|
+
actions.errors.push({ step: 'expo-dev', error: e instanceof Error ? e.message : String(e) });
|
|
200
207
|
}
|
|
201
208
|
try {
|
|
202
|
-
|
|
209
|
+
// Legacy cleanups (best-effort): older runs used separate state dirs.
|
|
210
|
+
actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
|
|
211
|
+
const killedDev = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile-dev', stateFileName: 'mobile.state.json', envPath, json });
|
|
212
|
+
const killedLegacy = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
|
|
213
|
+
actions.mobile = [...killedDev, ...killedLegacy];
|
|
203
214
|
} catch (e) {
|
|
204
215
|
actions.errors.push({ step: 'expo-mobile', error: e instanceof Error ? e.message : String(e) });
|
|
205
216
|
}
|