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
package/scripts/stack.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import './utils/env/env.mjs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { chmod, copyFile, mkdir, open, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
// NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
|
|
7
7
|
import { homedir } from 'node:os';
|
|
@@ -9,8 +9,19 @@ import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs
|
|
|
9
9
|
|
|
10
10
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
11
11
|
import { killProcessTree, run, runCapture } from './utils/proc/proc.mjs';
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
componentDirEnvKey,
|
|
14
|
+
coerceHappyMonorepoRootFromPath,
|
|
15
|
+
getComponentDir,
|
|
16
|
+
getComponentsDir,
|
|
17
|
+
getHappyStacksHomeDir,
|
|
18
|
+
getLegacyStorageRoot,
|
|
19
|
+
getRootDir,
|
|
20
|
+
getStacksStorageRoot,
|
|
21
|
+
happyMonorepoSubdirForComponent,
|
|
22
|
+
resolveStackEnvPath,
|
|
23
|
+
} from './utils/paths/paths.mjs';
|
|
24
|
+
import { isTcpPortFree, listListenPids, pickNextFreeTcpPort } from './utils/net/ports.mjs';
|
|
14
25
|
import {
|
|
15
26
|
createWorktree,
|
|
16
27
|
createWorktreeFromBaseWorktree,
|
|
@@ -19,7 +30,7 @@ import {
|
|
|
19
30
|
resolveComponentSpecToDir,
|
|
20
31
|
worktreeSpecFromDir,
|
|
21
32
|
} from './utils/git/worktrees.mjs';
|
|
22
|
-
import { isTty, prompt,
|
|
33
|
+
import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
23
34
|
import { parseEnvToObject } from './utils/env/dotenv.mjs';
|
|
24
35
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
25
36
|
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
@@ -27,10 +38,10 @@ import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
|
|
|
27
38
|
import { stopStackWithEnv } from './utils/stack/stop.mjs';
|
|
28
39
|
import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
|
|
29
40
|
import { startDevServer } from './utils/dev/server.mjs';
|
|
30
|
-
import {
|
|
41
|
+
import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
|
|
31
42
|
import { requireDir } from './utils/proc/pm.mjs';
|
|
32
43
|
import { waitForHttpOk } from './utils/server/server.mjs';
|
|
33
|
-
import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
|
|
44
|
+
import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
|
|
34
45
|
import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
35
46
|
import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth/files.mjs';
|
|
36
47
|
import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
|
|
@@ -52,23 +63,31 @@ import {
|
|
|
52
63
|
import { killPid } from './utils/expo/expo.mjs';
|
|
53
64
|
import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
|
|
54
65
|
import { randomToken } from './utils/crypto/tokens.mjs';
|
|
55
|
-
import { killPidOwnedByStack } from './utils/proc/ownership.mjs';
|
|
66
|
+
import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
|
|
56
67
|
import { sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
57
68
|
import { isCursorInstalled, openWorkspaceInEditor, writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
|
|
69
|
+
import { readLastLines } from './utils/fs/tail.mjs';
|
|
70
|
+
import { defaultStackReleaseIdentity } from './utils/mobile/identifiers.mjs';
|
|
71
|
+
import { interactiveEdit, interactiveNew } from './utils/stack/interactive_stack_config.mjs';
|
|
58
72
|
|
|
59
73
|
function stackNameFromArg(positionals, idx) {
|
|
60
74
|
const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
|
|
61
75
|
return name;
|
|
62
76
|
}
|
|
63
77
|
|
|
64
|
-
function getDefaultPortStart() {
|
|
78
|
+
function getDefaultPortStart(stackName = null) {
|
|
65
79
|
const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
|
|
66
80
|
? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
|
|
67
81
|
: process.env.HAPPY_LOCAL_STACK_PORT_START?.trim()
|
|
68
82
|
? process.env.HAPPY_LOCAL_STACK_PORT_START.trim()
|
|
69
83
|
: '';
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
// Default port strategy:
|
|
85
|
+
// - main historically lives at 3005
|
|
86
|
+
// - non-main stacks should avoid 3005 to reduce accidental collisions/confusion
|
|
87
|
+
const target = (stackName ?? '').toString().trim() || (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
88
|
+
const fallback = target === 'main' ? 3005 : 3009;
|
|
89
|
+
const n = raw ? Number(raw) : fallback;
|
|
90
|
+
return Number.isFinite(n) ? n : fallback;
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
async function isPortFree(port) {
|
|
@@ -226,14 +245,48 @@ function stringifyEnv(env) {
|
|
|
226
245
|
const readExistingEnv = readTextOrEmpty;
|
|
227
246
|
|
|
228
247
|
function resolveDefaultComponentDirs({ rootDir }) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
248
|
+
function hasUnifiedLightSchema(serverDir) {
|
|
249
|
+
return (
|
|
250
|
+
existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma')) ||
|
|
251
|
+
existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function pickDefaultDir(name) {
|
|
232
256
|
const embedded = join(rootDir, 'components', name);
|
|
233
257
|
const workspace = join(getComponentsDir(rootDir), name);
|
|
234
|
-
|
|
235
|
-
|
|
258
|
+
// CRITICAL:
|
|
259
|
+
// In sandbox mode, never point stacks at the repo's embedded `components/*` checkouts.
|
|
260
|
+
// Sandboxes must use the sandbox workspace clones (HAPPY_STACKS_WORKSPACE_DIR/components/*),
|
|
261
|
+
// otherwise worktrees/branches collide with the user's real machine state.
|
|
262
|
+
return !isSandboxed() && existsSync(embedded) ? embedded : workspace;
|
|
236
263
|
}
|
|
264
|
+
|
|
265
|
+
const out = {};
|
|
266
|
+
|
|
267
|
+
const happyRoot = pickDefaultDir('happy');
|
|
268
|
+
const monoRoot = existsSync(happyRoot) ? coerceHappyMonorepoRootFromPath(happyRoot) : null;
|
|
269
|
+
|
|
270
|
+
if (monoRoot) {
|
|
271
|
+
const subdir = (component) => happyMonorepoSubdirForComponent(component);
|
|
272
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY = join(monoRoot, subdir('happy'));
|
|
273
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = join(monoRoot, subdir('happy-cli'));
|
|
274
|
+
const serverDir = join(monoRoot, subdir('happy-server'));
|
|
275
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = serverDir;
|
|
276
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = hasUnifiedLightSchema(serverDir)
|
|
277
|
+
? serverDir
|
|
278
|
+
: pickDefaultDir('happy-server-light');
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Prefer a single unified happy-server checkout for both flavors when it includes sqlite support.
|
|
283
|
+
const fullServerDir = pickDefaultDir('happy-server');
|
|
284
|
+
const hasUnifiedLight = hasUnifiedLightSchema(fullServerDir);
|
|
285
|
+
|
|
286
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY = pickDefaultDir('happy');
|
|
287
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = pickDefaultDir('happy-cli');
|
|
288
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = fullServerDir;
|
|
289
|
+
out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = hasUnifiedLight ? fullServerDir : pickDefaultDir('happy-server-light');
|
|
237
290
|
return out;
|
|
238
291
|
}
|
|
239
292
|
|
|
@@ -264,9 +317,46 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
|
|
|
264
317
|
// exported in their shell, it would otherwise "win" because utils/env.mjs only sets
|
|
265
318
|
// env vars if they are missing/empty.
|
|
266
319
|
const cleaned = { ...process.env };
|
|
320
|
+
const keepPrefixed = new Set([
|
|
321
|
+
// Stack/env pointers:
|
|
322
|
+
'HAPPY_LOCAL_ENV_FILE',
|
|
323
|
+
'HAPPY_STACKS_ENV_FILE',
|
|
324
|
+
'HAPPY_LOCAL_STACK',
|
|
325
|
+
'HAPPY_STACKS_STACK',
|
|
326
|
+
|
|
327
|
+
// Sandbox detection + policy (must propagate to child processes).
|
|
328
|
+
'HAPPY_STACKS_SANDBOX_DIR',
|
|
329
|
+
'HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL',
|
|
330
|
+
|
|
331
|
+
// Sandbox-enforced dirs (without these, sandbox isolation breaks).
|
|
332
|
+
'HAPPY_STACKS_CLI_ROOT_DISABLE',
|
|
333
|
+
'HAPPY_STACKS_CANONICAL_HOME_DIR',
|
|
334
|
+
'HAPPY_STACKS_HOME_DIR',
|
|
335
|
+
'HAPPY_STACKS_WORKSPACE_DIR',
|
|
336
|
+
'HAPPY_STACKS_RUNTIME_DIR',
|
|
337
|
+
'HAPPY_STACKS_STORAGE_DIR',
|
|
338
|
+
// Legacy prefix mirrors:
|
|
339
|
+
'HAPPY_LOCAL_CANONICAL_HOME_DIR',
|
|
340
|
+
'HAPPY_LOCAL_HOME_DIR',
|
|
341
|
+
'HAPPY_LOCAL_WORKSPACE_DIR',
|
|
342
|
+
'HAPPY_LOCAL_RUNTIME_DIR',
|
|
343
|
+
'HAPPY_LOCAL_STORAGE_DIR',
|
|
344
|
+
|
|
345
|
+
// Sandbox-safe UX knobs (keep consistent through stack wrappers).
|
|
346
|
+
'HAPPY_STACKS_VERBOSE',
|
|
347
|
+
'HAPPY_STACKS_UPDATE_CHECK',
|
|
348
|
+
'HAPPY_STACKS_UPDATE_CHECK_INTERVAL_MS',
|
|
349
|
+
'HAPPY_STACKS_UPDATE_NOTIFY_INTERVAL_MS',
|
|
350
|
+
|
|
351
|
+
// Guided auth flow coordination across wrappers.
|
|
352
|
+
// These are intentionally passed through even though most HAPPY_STACKS_* vars are scrubbed.
|
|
353
|
+
'HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH',
|
|
354
|
+
'HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH',
|
|
355
|
+
'HAPPY_STACKS_AUTH_FLOW',
|
|
356
|
+
'HAPPY_LOCAL_AUTH_FLOW',
|
|
357
|
+
]);
|
|
267
358
|
for (const k of Object.keys(cleaned)) {
|
|
268
|
-
if (k
|
|
269
|
-
if (k === 'HAPPY_LOCAL_STACK' || k === 'HAPPY_STACKS_STACK') continue;
|
|
359
|
+
if (keepPrefixed.has(k)) continue;
|
|
270
360
|
if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
|
|
271
361
|
delete cleaned[k];
|
|
272
362
|
}
|
|
@@ -344,110 +434,6 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
|
|
|
344
434
|
return await fn({ env, envPath, stackEnv, runtimeStatePath, runtimeState });
|
|
345
435
|
}
|
|
346
436
|
|
|
347
|
-
async function interactiveNew({ rootDir, rl, defaults }) {
|
|
348
|
-
const out = { ...defaults };
|
|
349
|
-
|
|
350
|
-
if (!out.stackName) {
|
|
351
|
-
out.stackName = (await rl.question('Stack name: ')).trim();
|
|
352
|
-
}
|
|
353
|
-
if (!out.stackName) {
|
|
354
|
-
throw new Error('[stack] stack name is required');
|
|
355
|
-
}
|
|
356
|
-
if (out.stackName === 'main') {
|
|
357
|
-
throw new Error('[stack] stack name \"main\" is reserved (use the default stack without creating it)');
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Server component selection
|
|
361
|
-
if (!out.serverComponent) {
|
|
362
|
-
const server = (await rl.question('Server component [happy-server-light|happy-server] (default: happy-server-light): ')).trim();
|
|
363
|
-
out.serverComponent = server || 'happy-server-light';
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Port
|
|
367
|
-
if (!out.port) {
|
|
368
|
-
const want = (await rl.question('Port (empty = ephemeral): ')).trim();
|
|
369
|
-
out.port = want ? Number(want) : null;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Remote for creating new worktrees (used by all "create new worktree" choices)
|
|
373
|
-
if (!out.createRemote) {
|
|
374
|
-
out.createRemote = await prompt(rl, 'Git remote for creating new worktrees (default: upstream): ', { defaultValue: 'upstream' });
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Component selections
|
|
378
|
-
for (const c of ['happy', 'happy-cli']) {
|
|
379
|
-
if (out.components[c] != null) continue;
|
|
380
|
-
out.components[c] = await promptWorktreeSource({
|
|
381
|
-
rl,
|
|
382
|
-
rootDir,
|
|
383
|
-
component: c,
|
|
384
|
-
stackName: out.stackName,
|
|
385
|
-
createRemote: out.createRemote,
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Server worktree selection (optional; only for the chosen server component)
|
|
390
|
-
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
391
|
-
if (out.components[serverComponent] == null) {
|
|
392
|
-
out.components[serverComponent] = await promptWorktreeSource({
|
|
393
|
-
rl,
|
|
394
|
-
rootDir,
|
|
395
|
-
component: serverComponent,
|
|
396
|
-
stackName: out.stackName,
|
|
397
|
-
createRemote: out.createRemote,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
return out;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }) {
|
|
405
|
-
const out = { ...defaults, stackName };
|
|
406
|
-
|
|
407
|
-
// Server component selection
|
|
408
|
-
const currentServer = existingEnv.HAPPY_STACKS_SERVER_COMPONENT ?? existingEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '';
|
|
409
|
-
const server = await prompt(
|
|
410
|
-
rl,
|
|
411
|
-
`Server component [happy-server-light|happy-server] (default: ${currentServer || 'happy-server-light'}): `,
|
|
412
|
-
{ defaultValue: currentServer || 'happy-server-light' }
|
|
413
|
-
);
|
|
414
|
-
out.serverComponent = server || 'happy-server-light';
|
|
415
|
-
|
|
416
|
-
// Port
|
|
417
|
-
const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
|
|
418
|
-
const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'ephemeral'}; type 'ephemeral' to unpin): `, { defaultValue: '' });
|
|
419
|
-
const wantTrimmed = wantPort.trim().toLowerCase();
|
|
420
|
-
out.port = wantTrimmed === 'ephemeral' ? null : wantPort ? Number(wantPort) : (currentPort ? Number(currentPort) : null);
|
|
421
|
-
|
|
422
|
-
// Remote for creating new worktrees
|
|
423
|
-
const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
|
|
424
|
-
out.createRemote = await prompt(rl, `Git remote for creating new worktrees (default: ${currentRemote || 'upstream'}): `, {
|
|
425
|
-
defaultValue: currentRemote || 'upstream',
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
// Worktree selections
|
|
429
|
-
for (const c of ['happy', 'happy-cli']) {
|
|
430
|
-
out.components[c] = await promptWorktreeSource({
|
|
431
|
-
rl,
|
|
432
|
-
rootDir,
|
|
433
|
-
component: c,
|
|
434
|
-
stackName,
|
|
435
|
-
createRemote: out.createRemote,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
440
|
-
out.components[serverComponent] = await promptWorktreeSource({
|
|
441
|
-
rl,
|
|
442
|
-
rootDir,
|
|
443
|
-
component: serverComponent,
|
|
444
|
-
stackName,
|
|
445
|
-
createRemote: out.createRemote,
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
return out;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
437
|
async function cmdNew({ rootDir, argv, emit = true }) {
|
|
452
438
|
const { flags, kv } = parseArgs(argv);
|
|
453
439
|
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
@@ -494,7 +480,7 @@ async function cmdNew({ rootDir, argv, emit = true }) {
|
|
|
494
480
|
if (!stackName) {
|
|
495
481
|
throw new Error(
|
|
496
482
|
'[stack] usage: happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] ' +
|
|
497
|
-
'[--happy=default|<owner/...>|<path>] [--happy-
|
|
483
|
+
'[--happy=default|<owner/...>|<path>] [--happy-server-light=...] ' +
|
|
498
484
|
'[--copy-auth-from=<stack|legacy>] [--link-auth] [--no-copy-auth] [--interactive] [--force-port]'
|
|
499
485
|
);
|
|
500
486
|
}
|
|
@@ -615,24 +601,88 @@ async function cmdNew({ rootDir, argv, emit = true }) {
|
|
|
615
601
|
}
|
|
616
602
|
}
|
|
617
603
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
604
|
+
let monorepoPinned = false;
|
|
605
|
+
|
|
606
|
+
// happy / happy-cli / happy-server can be a single monorepo (slopus/happy).
|
|
607
|
+
// Detect monorepo pinning by resolving the provided spec(s), rather than relying on the local default checkout.
|
|
608
|
+
const monoSpecs = [
|
|
609
|
+
{ component: 'happy', spec: config.components.happy },
|
|
610
|
+
{ component: 'happy-cli', spec: config.components['happy-cli'] },
|
|
611
|
+
{ component: 'happy-server', spec: config.components['happy-server'] },
|
|
612
|
+
].filter((x) => x.spec);
|
|
613
|
+
|
|
614
|
+
if (monoSpecs.length) {
|
|
615
|
+
const primary = monoSpecs[0];
|
|
616
|
+
const canon = (spec) => {
|
|
617
|
+
if (spec && typeof spec === 'object' && spec.create) {
|
|
618
|
+
const remote = String(spec.remote || 'upstream');
|
|
619
|
+
return `create:${String(spec.slug)}@${remote}`;
|
|
620
|
+
}
|
|
621
|
+
return String(spec);
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
let resolvedDir = '';
|
|
625
|
+
if (primary.spec && typeof primary.spec === 'object' && primary.spec.create) {
|
|
626
|
+
resolvedDir = await createWorktree({
|
|
627
|
+
rootDir,
|
|
628
|
+
component: primary.component,
|
|
629
|
+
slug: primary.spec.slug,
|
|
630
|
+
remoteName: primary.spec.remote || 'upstream',
|
|
631
|
+
});
|
|
632
|
+
} else {
|
|
633
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: primary.component, spec: primary.spec });
|
|
634
|
+
if (dir) resolvedDir = resolve(rootDir, dir);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const monoRoot = resolvedDir ? coerceHappyMonorepoRootFromPath(resolvedDir) : null;
|
|
638
|
+
if (monoRoot) {
|
|
639
|
+
for (const s of monoSpecs.slice(1)) {
|
|
640
|
+
if (canon(s.spec) !== canon(primary.spec)) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
`[stack] conflicting monorepo component specs.\n` +
|
|
643
|
+
`- happy: ${canon(config.components.happy)}\n` +
|
|
644
|
+
`- happy-cli: ${canon(config.components['happy-cli'])}\n` +
|
|
645
|
+
`- happy-server: ${canon(config.components['happy-server'])}\n` +
|
|
646
|
+
`Fix: in monorepo mode, pass only one of --happy/--happy-cli/--happy-server (or pass the same value for all).`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const subdir = (c) => happyMonorepoSubdirForComponent(c);
|
|
652
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = join(monoRoot, subdir('happy'));
|
|
653
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = join(monoRoot, subdir('happy-cli'));
|
|
654
|
+
const serverDir = join(monoRoot, subdir('happy-server'));
|
|
655
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = serverDir;
|
|
656
|
+
if (
|
|
657
|
+
existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma')) ||
|
|
658
|
+
existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
|
|
659
|
+
) {
|
|
660
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = serverDir;
|
|
661
|
+
}
|
|
662
|
+
monorepoPinned = true;
|
|
663
|
+
}
|
|
626
664
|
}
|
|
627
665
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
666
|
+
if (!monorepoPinned) {
|
|
667
|
+
// happy
|
|
668
|
+
const happySpec = config.components.happy;
|
|
669
|
+
if (happySpec && typeof happySpec === 'object' && happySpec.create) {
|
|
670
|
+
const dir = await createWorktree({ rootDir, component: 'happy', slug: happySpec.slug, remoteName: happySpec.remote || 'upstream' });
|
|
671
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = dir;
|
|
672
|
+
} else {
|
|
673
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy', spec: happySpec });
|
|
674
|
+
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = resolve(rootDir, dir);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// happy-cli
|
|
678
|
+
const cliSpec = config.components['happy-cli'];
|
|
679
|
+
if (cliSpec && typeof cliSpec === 'object' && cliSpec.create) {
|
|
680
|
+
const dir = await createWorktree({ rootDir, component: 'happy-cli', slug: cliSpec.slug, remoteName: cliSpec.remote || 'upstream' });
|
|
681
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = dir;
|
|
682
|
+
} else {
|
|
683
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-cli', spec: cliSpec });
|
|
684
|
+
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = resolve(rootDir, dir);
|
|
685
|
+
}
|
|
636
686
|
}
|
|
637
687
|
|
|
638
688
|
// Server component directory override (optional)
|
|
@@ -651,13 +701,15 @@ async function cmdNew({ rootDir, argv, emit = true }) {
|
|
|
651
701
|
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = resolve(rootDir, dir);
|
|
652
702
|
}
|
|
653
703
|
} else if (serverComponent === 'happy-server') {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
704
|
+
if (!monorepoPinned) {
|
|
705
|
+
const spec = config.components['happy-server'];
|
|
706
|
+
if (spec && typeof spec === 'object' && spec.create) {
|
|
707
|
+
const dir = await createWorktree({ rootDir, component: 'happy-server', slug: spec.slug, remoteName: spec.remote || 'upstream' });
|
|
708
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = dir;
|
|
709
|
+
} else {
|
|
710
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-server', spec });
|
|
711
|
+
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = resolve(rootDir, dir);
|
|
712
|
+
}
|
|
661
713
|
}
|
|
662
714
|
}
|
|
663
715
|
|
|
@@ -839,11 +891,83 @@ async function cmdEdit({ rootDir, argv }) {
|
|
|
839
891
|
}
|
|
840
892
|
};
|
|
841
893
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
894
|
+
const existingHappy = String(next.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? '').trim();
|
|
895
|
+
const happyMonorepo = Boolean(coerceHappyMonorepoRootFromPath(existingHappy)) || Boolean(coerceHappyMonorepoRootFromPath(getComponentDir(rootDir, 'happy', process.env)));
|
|
896
|
+
|
|
897
|
+
if (happyMonorepo) {
|
|
898
|
+
const monoSpecs = [
|
|
899
|
+
{ component: 'happy', spec: config.components.happy },
|
|
900
|
+
{ component: 'happy-cli', spec: config.components['happy-cli'] },
|
|
901
|
+
{ component: 'happy-server', spec: config.components['happy-server'] },
|
|
902
|
+
].filter((x) => x.spec);
|
|
903
|
+
|
|
904
|
+
if (monoSpecs.length) {
|
|
905
|
+
const primary = monoSpecs[0];
|
|
906
|
+
const canon = (spec) => {
|
|
907
|
+
if (spec && typeof spec === 'object' && spec.create) {
|
|
908
|
+
const remote = String(spec.remote || 'upstream');
|
|
909
|
+
return `create:${String(spec.slug)}@${remote}`;
|
|
910
|
+
}
|
|
911
|
+
return String(spec);
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
let resolvedDir = '';
|
|
915
|
+
if (primary.spec && typeof primary.spec === 'object' && primary.spec.create) {
|
|
916
|
+
resolvedDir = await createWorktree({
|
|
917
|
+
rootDir,
|
|
918
|
+
component: primary.component,
|
|
919
|
+
slug: primary.spec.slug,
|
|
920
|
+
remoteName: primary.spec.remote || next.HAPPY_STACKS_STACK_REMOTE,
|
|
921
|
+
});
|
|
922
|
+
} else {
|
|
923
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: primary.component, spec: primary.spec });
|
|
924
|
+
if (dir) resolvedDir = resolve(rootDir, dir);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const monoRoot = resolvedDir ? coerceHappyMonorepoRootFromPath(resolvedDir) : null;
|
|
928
|
+
if (monoRoot) {
|
|
929
|
+
for (const s of monoSpecs.slice(1)) {
|
|
930
|
+
if (canon(s.spec) !== canon(primary.spec)) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`[stack] edit: conflicting monorepo component specs.\n` +
|
|
933
|
+
`- happy: ${canon(config.components.happy)}\n` +
|
|
934
|
+
`- happy-cli: ${canon(config.components['happy-cli'])}\n` +
|
|
935
|
+
`- happy-server: ${canon(config.components['happy-server'])}\n` +
|
|
936
|
+
`Fix: in monorepo mode, pass only one of --happy/--happy-cli/--happy-server (or pass the same value for all).`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const subdir = (c) => happyMonorepoSubdirForComponent(c);
|
|
942
|
+
next.HAPPY_STACKS_COMPONENT_DIR_HAPPY = join(monoRoot, subdir('happy'));
|
|
943
|
+
next.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = join(monoRoot, subdir('happy-cli'));
|
|
944
|
+
const serverDir = join(monoRoot, subdir('happy-server'));
|
|
945
|
+
next.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = serverDir;
|
|
946
|
+
if (
|
|
947
|
+
existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma')) ||
|
|
948
|
+
existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
|
|
949
|
+
) {
|
|
950
|
+
next.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = serverDir;
|
|
951
|
+
}
|
|
952
|
+
} else {
|
|
953
|
+
await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
|
|
954
|
+
await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
|
|
955
|
+
await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
|
|
959
|
+
await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
|
|
960
|
+
await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
|
|
961
|
+
}
|
|
846
962
|
} else {
|
|
963
|
+
await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
|
|
964
|
+
await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
|
|
965
|
+
if (serverComponent === 'happy-server') {
|
|
966
|
+
await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (serverComponent === 'happy-server-light') {
|
|
847
971
|
await applyComponent('happy-server-light', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', config.components['happy-server-light']);
|
|
848
972
|
}
|
|
849
973
|
|
|
@@ -851,7 +975,7 @@ async function cmdEdit({ rootDir, argv }) {
|
|
|
851
975
|
printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
|
|
852
976
|
}
|
|
853
977
|
|
|
854
|
-
async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
|
|
978
|
+
async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {}, background = false }) {
|
|
855
979
|
await withStackEnv({
|
|
856
980
|
stackName,
|
|
857
981
|
extraEnv,
|
|
@@ -882,6 +1006,29 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
882
1006
|
// True restart = there was an active runner for this stack. If the stack is not running,
|
|
883
1007
|
// `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
|
|
884
1008
|
const isTrueRestart = wantsRestart && wasRunning;
|
|
1009
|
+
|
|
1010
|
+
// Restart semantics (stack mode):
|
|
1011
|
+
// - Stop stack-owned processes first (runner, daemon, Expo, etc.)
|
|
1012
|
+
// - Never kill arbitrary port listeners
|
|
1013
|
+
// - Preserve previous runtime ports in memory so a true restart can reuse them
|
|
1014
|
+
if (wantsRestart && !wantsJson) {
|
|
1015
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1016
|
+
try {
|
|
1017
|
+
await stopStackWithEnv({
|
|
1018
|
+
rootDir,
|
|
1019
|
+
stackName,
|
|
1020
|
+
baseDir,
|
|
1021
|
+
env,
|
|
1022
|
+
json: false,
|
|
1023
|
+
noDocker: false,
|
|
1024
|
+
aggressive: false,
|
|
1025
|
+
sweepOwned: true,
|
|
1026
|
+
});
|
|
1027
|
+
} catch {
|
|
1028
|
+
// ignore (fail-closed below on port checks)
|
|
1029
|
+
}
|
|
1030
|
+
await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
|
|
1031
|
+
}
|
|
885
1032
|
if (wasRunning) {
|
|
886
1033
|
if (!wantsRestart) {
|
|
887
1034
|
const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
|
|
@@ -913,12 +1060,16 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
913
1060
|
} else if (scriptPath === 'dev.mjs') {
|
|
914
1061
|
console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
|
|
915
1062
|
}
|
|
1063
|
+
|
|
1064
|
+
// Opt-in: allow starting mobile Metro alongside an already-running stack without restarting the runner.
|
|
1065
|
+
// This is important for workflows like re-running `setup-pr` with --mobile after the stack is already up.
|
|
1066
|
+
const wantsMobile = args.includes('--mobile') || args.includes('--with-mobile');
|
|
1067
|
+
if (wantsMobile) {
|
|
1068
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'mobile.mjs'), '--metro'], { cwd: rootDir, env });
|
|
1069
|
+
}
|
|
916
1070
|
return;
|
|
917
1071
|
}
|
|
918
|
-
// Restart:
|
|
919
|
-
await killPidOwnedByStack(existingOwnerPid, { stackName, envPath, cliHomeDir: (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').toString(), label: 'runner', json: false });
|
|
920
|
-
// Clear runtime state so we don't keep stale process PIDs; we'll re-create it for the new run below.
|
|
921
|
-
await deleteStackRuntimeStateFile(runtimeStatePath);
|
|
1072
|
+
// Restart: already handled above (stopStackWithEnv is ownership-gated).
|
|
922
1073
|
}
|
|
923
1074
|
|
|
924
1075
|
// Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
|
|
@@ -941,7 +1092,7 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
941
1092
|
}
|
|
942
1093
|
}
|
|
943
1094
|
|
|
944
|
-
const startPort = getDefaultPortStart();
|
|
1095
|
+
const startPort = getDefaultPortStart(stackName);
|
|
945
1096
|
const ports = {};
|
|
946
1097
|
|
|
947
1098
|
const parsePortOrNull = (v) => {
|
|
@@ -986,6 +1137,42 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
986
1137
|
for (const p of toCheck) {
|
|
987
1138
|
// eslint-disable-next-line no-await-in-loop
|
|
988
1139
|
if (!(await isTcpPortFree(p))) {
|
|
1140
|
+
if (isTrueRestart && !wantsJson) {
|
|
1141
|
+
// Try one more safe cleanup of stack-owned processes and re-check.
|
|
1142
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1143
|
+
try {
|
|
1144
|
+
await stopStackWithEnv({
|
|
1145
|
+
rootDir,
|
|
1146
|
+
stackName,
|
|
1147
|
+
baseDir,
|
|
1148
|
+
env,
|
|
1149
|
+
json: false,
|
|
1150
|
+
noDocker: false,
|
|
1151
|
+
aggressive: false,
|
|
1152
|
+
sweepOwned: true,
|
|
1153
|
+
});
|
|
1154
|
+
} catch {
|
|
1155
|
+
// ignore
|
|
1156
|
+
}
|
|
1157
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1158
|
+
if (await isTcpPortFree(p)) {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Last resort: if we can prove the listener is stack-owned, kill it.
|
|
1163
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1164
|
+
const pids = await listListenPids(p);
|
|
1165
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1166
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1167
|
+
for (const pid of pids) {
|
|
1168
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1169
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${p}`, json: false });
|
|
1170
|
+
}
|
|
1171
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1172
|
+
if (await isTcpPortFree(p)) {
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
989
1176
|
throw new Error(
|
|
990
1177
|
`[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
|
|
991
1178
|
`[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
|
|
@@ -1043,13 +1230,61 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1043
1230
|
: {}),
|
|
1044
1231
|
};
|
|
1045
1232
|
|
|
1233
|
+
// Background dev auth flow (automatic):
|
|
1234
|
+
// If we're starting `dev.mjs` in background and the stack is not authenticated yet,
|
|
1235
|
+
// keep the stack alive for guided login by marking this as an auth-flow so URL resolution
|
|
1236
|
+
// fails closed (never opens server port as "UI").
|
|
1237
|
+
//
|
|
1238
|
+
// IMPORTANT:
|
|
1239
|
+
// We must NOT start the daemon before credentials exist in orchestrated flows (setup-pr/review-pr),
|
|
1240
|
+
// because the daemon can enter its own auth flow and become stranded (lock held, no machine registration).
|
|
1241
|
+
if (background && scriptPath === 'dev.mjs') {
|
|
1242
|
+
const startUi = !args.includes('--no-ui') && (env.HAPPY_LOCAL_UI ?? '1').toString().trim() !== '0';
|
|
1243
|
+
const startDaemon = !args.includes('--no-daemon') && (env.HAPPY_LOCAL_DAEMON ?? '1').toString().trim() !== '0';
|
|
1244
|
+
if (startUi && startDaemon) {
|
|
1245
|
+
try {
|
|
1246
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1247
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1248
|
+
const hasCreds = existsSync(join(cliHomeDir, 'access.key'));
|
|
1249
|
+
if (!hasCreds) {
|
|
1250
|
+
childEnv.HAPPY_STACKS_AUTH_FLOW = '1';
|
|
1251
|
+
childEnv.HAPPY_LOCAL_AUTH_FLOW = '1';
|
|
1252
|
+
}
|
|
1253
|
+
} catch {
|
|
1254
|
+
// If we can't resolve CLI home dir, skip auto auth-flow markers (best-effort).
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Background mode: send runner output to a stack-scoped log file so quiet flows can
|
|
1260
|
+
// remain clean while still providing actionable error logs.
|
|
1261
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1262
|
+
const logsDir = join(stackBaseDir, 'logs');
|
|
1263
|
+
const logPath = join(logsDir, `${scriptPath.replace(/\.mjs$/, '')}.${Date.now()}.log`);
|
|
1264
|
+
if (background) {
|
|
1265
|
+
await ensureDir(logsDir);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
let logHandle = null;
|
|
1269
|
+
let outFd = null;
|
|
1270
|
+
if (background) {
|
|
1271
|
+
logHandle = await open(logPath, 'a');
|
|
1272
|
+
outFd = logHandle.fd;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1046
1275
|
// Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
|
|
1047
1276
|
const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
|
|
1048
1277
|
cwd: rootDir,
|
|
1049
1278
|
env: childEnv,
|
|
1050
|
-
stdio: 'inherit',
|
|
1279
|
+
stdio: background ? ['ignore', outFd ?? 'ignore', outFd ?? 'ignore'] : 'inherit',
|
|
1051
1280
|
shell: false,
|
|
1281
|
+
detached: background && process.platform !== 'win32',
|
|
1052
1282
|
});
|
|
1283
|
+
try {
|
|
1284
|
+
await logHandle?.close();
|
|
1285
|
+
} catch {
|
|
1286
|
+
// ignore
|
|
1287
|
+
}
|
|
1053
1288
|
|
|
1054
1289
|
// Record the chosen ports immediately (before the runner finishes booting), so other stack commands
|
|
1055
1290
|
// can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
|
|
@@ -1059,8 +1294,104 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1059
1294
|
ephemeral: true,
|
|
1060
1295
|
ownerPid: child.pid,
|
|
1061
1296
|
ports,
|
|
1297
|
+
...(background ? { logs: { runner: logPath } } : {}),
|
|
1062
1298
|
}).catch(() => {});
|
|
1063
1299
|
|
|
1300
|
+
if (background) {
|
|
1301
|
+
// Keep stack.runtime.json so stack-scoped stop/restart can manage this runner.
|
|
1302
|
+
// This mode is used by higher-level commands that want to run guided auth steps
|
|
1303
|
+
// without mixing them into server logs.
|
|
1304
|
+
const internalServerUrl = `http://127.0.0.1:${ports.server}`;
|
|
1305
|
+
|
|
1306
|
+
// Fail fast if the runner dies immediately or never exposes HTTP.
|
|
1307
|
+
// IMPORTANT: do not treat "some process answered /health" as success unless our runner
|
|
1308
|
+
// is still alive. Otherwise, if the chosen port is already in use, the runner can exit
|
|
1309
|
+
// and a different stack/process could satisfy the health check (leading to confusing
|
|
1310
|
+
// follow-on behavior like auth using the wrong port).
|
|
1311
|
+
try {
|
|
1312
|
+
let exited = null;
|
|
1313
|
+
const exitPromise = new Promise((resolvePromise) => {
|
|
1314
|
+
child.once('exit', (code, sig) => {
|
|
1315
|
+
exited = { kind: 'exit', code: code ?? 0, sig: sig ?? null };
|
|
1316
|
+
resolvePromise(exited);
|
|
1317
|
+
});
|
|
1318
|
+
child.once('error', (err) => {
|
|
1319
|
+
exited = { kind: 'error', error: err instanceof Error ? err.message : String(err) };
|
|
1320
|
+
resolvePromise(exited);
|
|
1321
|
+
});
|
|
1322
|
+
});
|
|
1323
|
+
const readyPromise = (async () => {
|
|
1324
|
+
const timeoutMsRaw =
|
|
1325
|
+
(process.env.HAPPY_STACKS_STACK_BACKGROUND_READY_TIMEOUT_MS ??
|
|
1326
|
+
process.env.HAPPY_LOCAL_STACK_BACKGROUND_READY_TIMEOUT_MS ??
|
|
1327
|
+
'180000')
|
|
1328
|
+
.toString()
|
|
1329
|
+
.trim();
|
|
1330
|
+
const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 180_000;
|
|
1331
|
+
await waitForHttpOk(`${internalServerUrl}/health`, {
|
|
1332
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 180_000,
|
|
1333
|
+
intervalMs: 300,
|
|
1334
|
+
});
|
|
1335
|
+
return { kind: 'ready' };
|
|
1336
|
+
})();
|
|
1337
|
+
|
|
1338
|
+
const first = await Promise.race([exitPromise, readyPromise]);
|
|
1339
|
+
if (first.kind !== 'ready') {
|
|
1340
|
+
throw new Error(`[stack] ${stackName}: runner exited before becoming ready. log: ${logPath}`);
|
|
1341
|
+
}
|
|
1342
|
+
// Even if /health responded, ensure our runner is still alive.
|
|
1343
|
+
// (Prevents false positives when another process owns the port.)
|
|
1344
|
+
if (exited && exited.kind !== 'ready') {
|
|
1345
|
+
throw new Error(`[stack] ${stackName}: runner reported ready but exited immediately. log: ${logPath}`);
|
|
1346
|
+
}
|
|
1347
|
+
if (!isPidAlive(child.pid)) {
|
|
1348
|
+
throw new Error(
|
|
1349
|
+
`[stack] ${stackName}: runner health check passed, but runner is not running.\n` +
|
|
1350
|
+
`[stack] This usually means the chosen port (${ports.server}) is already in use by another process.\n` +
|
|
1351
|
+
`[stack] log: ${logPath}`
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
} catch (e) {
|
|
1355
|
+
// Attach some log context so failures are debuggable even when a higher-level
|
|
1356
|
+
// command cleans up the sandbox directory afterwards.
|
|
1357
|
+
try {
|
|
1358
|
+
const tail = await readLastLines(logPath, 160);
|
|
1359
|
+
if (tail && e instanceof Error) {
|
|
1360
|
+
e.message = `${e.message}\n\n[stack] last runner log lines:\n${tail}`;
|
|
1361
|
+
}
|
|
1362
|
+
} catch {
|
|
1363
|
+
// ignore
|
|
1364
|
+
}
|
|
1365
|
+
// Best-effort cleanup on boot failure.
|
|
1366
|
+
try {
|
|
1367
|
+
// We spawned this runner process, so we can safely terminate it without relying
|
|
1368
|
+
// on ownership heuristics (which can be unreliable on some platforms due to `ps` truncation).
|
|
1369
|
+
if (background && process.platform !== 'win32') {
|
|
1370
|
+
try {
|
|
1371
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1372
|
+
} catch {
|
|
1373
|
+
// ignore
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
child.kill('SIGTERM');
|
|
1378
|
+
} catch {
|
|
1379
|
+
// ignore
|
|
1380
|
+
}
|
|
1381
|
+
} catch {
|
|
1382
|
+
// ignore
|
|
1383
|
+
}
|
|
1384
|
+
await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
|
|
1385
|
+
throw e;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (!wantsJson) {
|
|
1389
|
+
console.log(`[stack] ${stackName}: logs: ${logPath}`);
|
|
1390
|
+
}
|
|
1391
|
+
try { child.unref(); } catch { /* ignore */ }
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1064
1395
|
try {
|
|
1065
1396
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
1066
1397
|
child.on('error', rejectPromise);
|
|
@@ -1079,6 +1410,28 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1079
1410
|
}
|
|
1080
1411
|
|
|
1081
1412
|
// Pinned port stack: run normally under the pinned env.
|
|
1413
|
+
if (background) {
|
|
1414
|
+
throw new Error('[stack] --background is only supported for ephemeral-port stacks');
|
|
1415
|
+
}
|
|
1416
|
+
if (wantsRestart && !wantsJson) {
|
|
1417
|
+
const pinnedPort = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
|
|
1418
|
+
if (pinnedPort && !(await isTcpPortFree(pinnedPort))) {
|
|
1419
|
+
// Last resort: kill listener only if it is stack-owned.
|
|
1420
|
+
const pids = await listListenPids(pinnedPort);
|
|
1421
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1422
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1423
|
+
for (const pid of pids) {
|
|
1424
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1425
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${pinnedPort}`, json: false });
|
|
1426
|
+
}
|
|
1427
|
+
if (!(await isTcpPortFree(pinnedPort))) {
|
|
1428
|
+
throw new Error(
|
|
1429
|
+
`[stack] ${stackName}: server port ${pinnedPort} is not free on restart.\n` +
|
|
1430
|
+
`[stack] Refusing to kill unknown listeners. Stop the process using it, or change the pinned port.`
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1082
1435
|
await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
|
|
1083
1436
|
},
|
|
1084
1437
|
});
|
|
@@ -1122,9 +1475,25 @@ async function cmdService({ rootDir, stackName, svcCmd }) {
|
|
|
1122
1475
|
});
|
|
1123
1476
|
}
|
|
1124
1477
|
|
|
1478
|
+
async function getRuntimePortExtraEnv(stackName) {
|
|
1479
|
+
const runtimeStatePath = getStackRuntimeStatePath(stackName);
|
|
1480
|
+
const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
|
|
1481
|
+
const runtimePort = Number(runtimeState?.ports?.server);
|
|
1482
|
+
return Number.isFinite(runtimePort) && runtimePort > 0
|
|
1483
|
+
? {
|
|
1484
|
+
// Ephemeral stacks (PR stacks) store their chosen ports in stack.runtime.json, not the env file.
|
|
1485
|
+
// Ensure stack-scoped commands that compute URLs don't fall back to 3005 (main default).
|
|
1486
|
+
HAPPY_STACKS_SERVER_PORT: String(runtimePort),
|
|
1487
|
+
HAPPY_LOCAL_SERVER_PORT: String(runtimePort),
|
|
1488
|
+
}
|
|
1489
|
+
: null;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1125
1492
|
async function cmdTailscale({ rootDir, stackName, subcmd, args }) {
|
|
1493
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
1126
1494
|
await withStackEnv({
|
|
1127
1495
|
stackName,
|
|
1496
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
1128
1497
|
fn: async ({ env }) => {
|
|
1129
1498
|
await run(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), subcmd, ...args], { cwd: rootDir, env });
|
|
1130
1499
|
},
|
|
@@ -1146,7 +1515,21 @@ async function cmdWt({ rootDir, stackName, args }) {
|
|
|
1146
1515
|
// Forward to scripts/worktrees.mjs under the stack env.
|
|
1147
1516
|
// This makes `happys stack wt <name> -- ...` behave exactly like `happys wt ...`,
|
|
1148
1517
|
// but read/write the stack env file (HAPPY_STACKS_ENV_FILE / legacy: HAPPY_LOCAL_ENV_FILE) instead of repo env.local.
|
|
1149
|
-
|
|
1518
|
+
let forwarded = args[0] === '--' ? args.slice(1) : args;
|
|
1519
|
+
|
|
1520
|
+
// Stack users usually want to see what *this stack* is using (active checkout),
|
|
1521
|
+
// not an exhaustive enumeration of every worktree on disk.
|
|
1522
|
+
//
|
|
1523
|
+
// `happys wt list` defaults to showing all worktrees. In stack mode, default to
|
|
1524
|
+
// an active-only view unless the caller opts into `--all`.
|
|
1525
|
+
if (forwarded[0] === 'list') {
|
|
1526
|
+
const wantsAll = forwarded.includes('--all') || forwarded.includes('--all-worktrees');
|
|
1527
|
+
const wantsActive = forwarded.includes('--active') || forwarded.includes('--active-only');
|
|
1528
|
+
if (!wantsAll && !wantsActive) {
|
|
1529
|
+
forwarded = [...forwarded, '--active'];
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1150
1533
|
await withStackEnv({
|
|
1151
1534
|
stackName,
|
|
1152
1535
|
fn: async ({ env }) => {
|
|
@@ -1159,8 +1542,10 @@ async function cmdAuth({ rootDir, stackName, args }) {
|
|
|
1159
1542
|
// Forward to scripts/auth.mjs under the stack env.
|
|
1160
1543
|
// This makes `happys stack auth <name> ...` resolve CLI home/urls for that stack.
|
|
1161
1544
|
const forwarded = args[0] === '--' ? args.slice(1) : args;
|
|
1545
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
1162
1546
|
await withStackEnv({
|
|
1163
1547
|
stackName,
|
|
1548
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
1164
1549
|
fn: async ({ env }) => {
|
|
1165
1550
|
await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...forwarded], { cwd: rootDir, env });
|
|
1166
1551
|
},
|
|
@@ -1795,7 +2180,7 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1795
2180
|
|
|
1796
2181
|
const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
|
|
1797
2182
|
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
1798
|
-
const publicServerUrl = `http://localhost:${serverPort}
|
|
2183
|
+
const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}`, { stackName: name });
|
|
1799
2184
|
|
|
1800
2185
|
const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
|
|
1801
2186
|
const children = [];
|
|
@@ -1815,9 +2200,14 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1815
2200
|
serverComponent === 'happy-server'
|
|
1816
2201
|
? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
|
|
1817
2202
|
: env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT;
|
|
1818
|
-
const resolvedServerDir =
|
|
1819
|
-
|
|
1820
|
-
|
|
2203
|
+
const resolvedServerDir =
|
|
2204
|
+
(serverDir ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT ?? '').toString().trim() ||
|
|
2205
|
+
getComponentDir(rootDir, serverComponent);
|
|
2206
|
+
const resolvedCliDir =
|
|
2207
|
+
(env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() ||
|
|
2208
|
+
getComponentDir(rootDir, 'happy-cli');
|
|
2209
|
+
const resolvedUiDir =
|
|
2210
|
+
(env.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY ?? '').toString().trim() || getComponentDir(rootDir, 'happy');
|
|
1821
2211
|
|
|
1822
2212
|
await requireDir(serverComponent, resolvedServerDir);
|
|
1823
2213
|
await requireDir('happy-cli', resolvedCliDir);
|
|
@@ -1844,9 +2234,10 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1844
2234
|
});
|
|
1845
2235
|
serverProc = started.serverProc;
|
|
1846
2236
|
|
|
1847
|
-
// Start Expo web
|
|
1848
|
-
const uiRes = await
|
|
2237
|
+
// Start Expo (web) so /terminal/connect exists for happy-cli web auth.
|
|
2238
|
+
const uiRes = await ensureDevExpoServer({
|
|
1849
2239
|
startUi: true,
|
|
2240
|
+
startMobile: false,
|
|
1850
2241
|
uiDir: resolvedUiDir,
|
|
1851
2242
|
autostart,
|
|
1852
2243
|
baseEnv: env,
|
|
@@ -1865,10 +2256,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1865
2256
|
}
|
|
1866
2257
|
|
|
1867
2258
|
console.log('');
|
|
1868
|
-
const uiHost = resolveLocalhostHost({ stackMode: true, stackName: name });
|
|
1869
2259
|
const uiPort = uiRes?.port;
|
|
1870
|
-
const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
|
|
1871
2260
|
const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
|
|
2261
|
+
const uiRoot = uiRootLocalhost ? await preferStackLocalhostUrl(uiRootLocalhost, { stackName: name }) : null;
|
|
1872
2262
|
const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
|
|
1873
2263
|
|
|
1874
2264
|
console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
|
|
@@ -2001,6 +2391,150 @@ async function readStackEnvObject(stackName) {
|
|
|
2001
2391
|
return { envPath, env };
|
|
2002
2392
|
}
|
|
2003
2393
|
|
|
2394
|
+
function getTodayYmd() {
|
|
2395
|
+
const now = new Date();
|
|
2396
|
+
const y = String(now.getFullYear());
|
|
2397
|
+
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
2398
|
+
const d = String(now.getDate()).padStart(2, '0');
|
|
2399
|
+
return `${y}-${m}-${d}`;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
async function cmdArchiveStack({ rootDir, argv, stackName }) {
|
|
2403
|
+
const { flags, kv } = parseArgs(argv);
|
|
2404
|
+
const json = wantsJson(argv, { flags });
|
|
2405
|
+
const dryRun = flags.has('--dry-run');
|
|
2406
|
+
const date = (kv.get('--date') ?? '').toString().trim() || getTodayYmd();
|
|
2407
|
+
|
|
2408
|
+
if (!stackExistsSync(stackName)) {
|
|
2409
|
+
throw new Error(`[stack] archive: stack does not exist: ${stackName}`);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const { env } = await readStackEnvObject(stackName);
|
|
2413
|
+
const serverComponent = parseServerComponentFromEnv(env);
|
|
2414
|
+
const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
2415
|
+
|
|
2416
|
+
const componentsDir = getComponentsDir(rootDir);
|
|
2417
|
+
const workspaceDir = dirname(componentsDir);
|
|
2418
|
+
const worktreesRoot = join(componentsDir, '.worktrees');
|
|
2419
|
+
|
|
2420
|
+
// Collect unique git worktree roots referenced by this stack.
|
|
2421
|
+
const byRoot = new Map();
|
|
2422
|
+
for (const component of components) {
|
|
2423
|
+
const key = envKeyForComponentDir({ serverComponent, component });
|
|
2424
|
+
const legacyKey = key.replace('HAPPY_STACKS_', 'HAPPY_LOCAL_');
|
|
2425
|
+
const raw = (env[key] ?? env[legacyKey] ?? '').toString().trim();
|
|
2426
|
+
if (!raw) continue;
|
|
2427
|
+
const abs = isAbsolute(raw) ? raw : resolve(workspaceDir, raw);
|
|
2428
|
+
// Only archive paths that live under components/.worktrees/.
|
|
2429
|
+
const rel = relative(worktreesRoot, abs);
|
|
2430
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
|
|
2431
|
+
try {
|
|
2432
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2433
|
+
const top = (await runCapture('git', ['rev-parse', '--show-toplevel'], { cwd: abs })).trim();
|
|
2434
|
+
if (!top) continue;
|
|
2435
|
+
if (!byRoot.has(top)) {
|
|
2436
|
+
byRoot.set(top, { component, dir: top });
|
|
2437
|
+
}
|
|
2438
|
+
} catch {
|
|
2439
|
+
// ignore invalid git dirs
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
const { baseDir } = resolveStackEnvPath(stackName);
|
|
2444
|
+
const destStackDir = join(dirname(baseDir), '.archived', date, stackName);
|
|
2445
|
+
|
|
2446
|
+
// Safety: avoid archiving a worktree that is still actively referenced by other stacks.
|
|
2447
|
+
// If we did, we'd break those stacks by moving their active checkout.
|
|
2448
|
+
if (!dryRun && byRoot.size) {
|
|
2449
|
+
const otherStacks = new Map(); // envPath -> Set(keys)
|
|
2450
|
+
const otherNames = new Set();
|
|
2451
|
+
|
|
2452
|
+
for (const wt of byRoot.values()) {
|
|
2453
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2454
|
+
const out = await runCapture(
|
|
2455
|
+
process.execPath,
|
|
2456
|
+
[join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', wt.component, wt.dir, '--dry-run', `--date=${date}`, '--json'],
|
|
2457
|
+
{ cwd: rootDir, env: process.env }
|
|
2458
|
+
);
|
|
2459
|
+
const info = JSON.parse(out);
|
|
2460
|
+
const linked = Array.isArray(info.linkedStacks) ? info.linkedStacks : [];
|
|
2461
|
+
for (const s of linked) {
|
|
2462
|
+
if (!s?.name || s.name === stackName) continue;
|
|
2463
|
+
otherNames.add(s.name);
|
|
2464
|
+
const envPath = String(s.envPath ?? '').trim();
|
|
2465
|
+
if (!envPath) continue;
|
|
2466
|
+
const set = otherStacks.get(envPath) ?? new Set();
|
|
2467
|
+
for (const k of Array.isArray(s.keys) ? s.keys : []) {
|
|
2468
|
+
if (k) set.add(String(k));
|
|
2469
|
+
}
|
|
2470
|
+
otherStacks.set(envPath, set);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
if (otherNames.size) {
|
|
2475
|
+
const names = Array.from(otherNames).sort().join(', ');
|
|
2476
|
+
if (json || !isTty()) {
|
|
2477
|
+
throw new Error(`[stack] archive: worktree(s) are still referenced by other stacks: ${names}. Resolve first (detach or archive those stacks).`);
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
const action = await withRl(async (rl) => {
|
|
2481
|
+
return await promptSelect(rl, {
|
|
2482
|
+
title: `Worktree(s) referenced by "${stackName}" are still in use by other stacks: ${names}`,
|
|
2483
|
+
options: [
|
|
2484
|
+
{ label: 'abort (recommended)', value: 'abort' },
|
|
2485
|
+
{ label: 'detach those stacks from the shared worktree(s)', value: 'detach' },
|
|
2486
|
+
{ label: 'archive the linked stacks as well', value: 'archive-stacks' },
|
|
2487
|
+
],
|
|
2488
|
+
defaultIndex: 0,
|
|
2489
|
+
});
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
if (action === 'abort') {
|
|
2493
|
+
throw new Error('[stack] archive aborted');
|
|
2494
|
+
}
|
|
2495
|
+
if (action === 'archive-stacks') {
|
|
2496
|
+
for (const name of Array.from(otherNames).sort()) {
|
|
2497
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2498
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'archive', name, `--date=${date}`], { cwd: rootDir, env: process.env });
|
|
2499
|
+
}
|
|
2500
|
+
} else {
|
|
2501
|
+
for (const [envPath, keys] of otherStacks.entries()) {
|
|
2502
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2503
|
+
await ensureEnvFilePruned({ envPath, removeKeys: Array.from(keys) });
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
if (dryRun) {
|
|
2510
|
+
return {
|
|
2511
|
+
ok: true,
|
|
2512
|
+
dryRun: true,
|
|
2513
|
+
stackName,
|
|
2514
|
+
date,
|
|
2515
|
+
stackBaseDir: baseDir,
|
|
2516
|
+
archivedStackDir: destStackDir,
|
|
2517
|
+
worktrees: Array.from(byRoot.values()),
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
await mkdir(dirname(destStackDir), { recursive: true });
|
|
2522
|
+
await rename(baseDir, destStackDir);
|
|
2523
|
+
|
|
2524
|
+
const archivedWorktrees = [];
|
|
2525
|
+
for (const wt of byRoot.values()) {
|
|
2526
|
+
if (!existsSync(wt.dir)) continue;
|
|
2527
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2528
|
+
const out = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', wt.component, wt.dir, `--date=${date}`, '--json'], {
|
|
2529
|
+
cwd: rootDir,
|
|
2530
|
+
env: process.env,
|
|
2531
|
+
});
|
|
2532
|
+
archivedWorktrees.push(JSON.parse(out));
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return { ok: true, dryRun: false, stackName, date, archivedStackDir: destStackDir, archivedWorktrees };
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2004
2538
|
function envKeyForComponentDir({ serverComponent, component }) {
|
|
2005
2539
|
if (component === 'happy') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY';
|
|
2006
2540
|
if (component === 'happy-cli') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI';
|
|
@@ -2058,14 +2592,14 @@ async function cmdDuplicate({ rootDir, argv }) {
|
|
|
2058
2592
|
if (!rawDir) continue;
|
|
2059
2593
|
|
|
2060
2594
|
let nextDir = rawDir;
|
|
2061
|
-
if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
|
|
2062
|
-
const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir });
|
|
2595
|
+
if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir, env: fromEnv })) {
|
|
2596
|
+
const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir, env: fromEnv });
|
|
2063
2597
|
if (spec) {
|
|
2064
2598
|
const [owner, ...restParts] = spec.split('/').filter(Boolean);
|
|
2065
2599
|
const rest = restParts.join('/');
|
|
2066
2600
|
const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
|
|
2067
2601
|
|
|
2068
|
-
const repoDir = join(getComponentsDir(rootDir), component);
|
|
2602
|
+
const repoDir = join(getComponentsDir(rootDir, fromEnv), component);
|
|
2069
2603
|
const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
|
|
2070
2604
|
// Base on the existing worktree's HEAD/branch so we get the same commit.
|
|
2071
2605
|
nextDir = await createWorktreeFromBaseWorktree({
|
|
@@ -2075,6 +2609,7 @@ async function cmdDuplicate({ rootDir, argv }) {
|
|
|
2075
2609
|
baseWorktreeSpec: spec,
|
|
2076
2610
|
remoteName,
|
|
2077
2611
|
depsMode,
|
|
2612
|
+
env: fromEnv,
|
|
2078
2613
|
});
|
|
2079
2614
|
}
|
|
2080
2615
|
}
|
|
@@ -2160,28 +2695,29 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2160
2695
|
json,
|
|
2161
2696
|
data: {
|
|
2162
2697
|
usage:
|
|
2163
|
-
'happys stack pr <name> --happy=<pr-url|number> [--happy-
|
|
2698
|
+
'happys stack pr <name> --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--server=happy-server|happy-server-light] [--remote=upstream] [--deps=none|link|install|link-or-install] [--seed-auth] [--copy-auth-from=<stack>] [--with-infra] [--auth-force] [--dev|--start] [--background] [--mobile] [--expo-tailscale] [--json] [-- <stack dev/start args...>]',
|
|
2164
2699
|
},
|
|
2165
2700
|
text: [
|
|
2166
2701
|
'[stack] usage:',
|
|
2167
|
-
' happys stack pr <name> --happy=<pr-url|number> [--happy-
|
|
2168
|
-
' [--seed-auth] [--copy-auth-from=<stack
|
|
2169
|
-
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force]',
|
|
2702
|
+
' happys stack pr <name> --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start]',
|
|
2703
|
+
' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
|
|
2704
|
+
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
|
|
2705
|
+
' [--mobile] # also start Expo dev-client Metro for mobile',
|
|
2706
|
+
' [--expo-tailscale] # forward Expo to Tailscale interface for remote access',
|
|
2170
2707
|
' [--json] [-- <stack dev/start args...>]',
|
|
2171
2708
|
'',
|
|
2172
2709
|
'examples:',
|
|
2173
2710
|
' # Create stack + check out PRs + start dev UI',
|
|
2174
2711
|
' happys stack pr pr123 \\',
|
|
2175
2712
|
' --happy=https://github.com/slopus/happy/pull/123 \\',
|
|
2176
|
-
' --happy-cli=https://github.com/slopus/happy-cli/pull/456 \\',
|
|
2177
2713
|
' --seed-auth --copy-auth-from=dev-auth \\',
|
|
2178
2714
|
' --dev',
|
|
2179
2715
|
'',
|
|
2180
2716
|
' # Use numeric PR refs (remote defaults to upstream)',
|
|
2181
|
-
' happys stack pr pr123 --happy=123 --
|
|
2717
|
+
' happys stack pr pr123 --happy=123 --seed-auth --copy-auth-from=dev-auth --dev',
|
|
2182
2718
|
'',
|
|
2183
2719
|
' # Reuse an existing non-stacks Happy install for auth seeding',
|
|
2184
|
-
'
|
|
2720
|
+
' (deprecated) legacy ~/.happy is not supported for reliable seeding',
|
|
2185
2721
|
'',
|
|
2186
2722
|
'notes:',
|
|
2187
2723
|
' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
|
|
@@ -2209,7 +2745,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2209
2745
|
);
|
|
2210
2746
|
}
|
|
2211
2747
|
|
|
2212
|
-
const
|
|
2748
|
+
const remoteNameFromArg = (kv.get('--remote') ?? '').trim();
|
|
2213
2749
|
const depsMode = (kv.get('--deps') ?? '').trim();
|
|
2214
2750
|
|
|
2215
2751
|
const prHappy = (kv.get('--happy') ?? '').trim();
|
|
@@ -2226,6 +2762,22 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2226
2762
|
throw new Error('[stack] pr: cannot specify both --happy-server and --happy-server-light');
|
|
2227
2763
|
}
|
|
2228
2764
|
|
|
2765
|
+
const happyMonorepoActive = Boolean(coerceHappyMonorepoRootFromPath(getComponentDir(rootDir, 'happy', process.env)));
|
|
2766
|
+
if (happyMonorepoActive) {
|
|
2767
|
+
if (prCli) {
|
|
2768
|
+
throw new Error(
|
|
2769
|
+
'[stack] pr: --happy-cli is not supported when using the slopus/happy monorepo.\n' +
|
|
2770
|
+
'Fix: use --happy=<pr> to pin the monorepo (UI + CLI + server) in one worktree.'
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
if (prServer) {
|
|
2774
|
+
throw new Error(
|
|
2775
|
+
'[stack] pr: --happy-server is not supported when using the slopus/happy monorepo.\n' +
|
|
2776
|
+
'Fix: use --happy=<pr> to pin the monorepo (UI + CLI + server) in one worktree.'
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2229
2781
|
const serverFromArg = (kv.get('--server') ?? '').trim();
|
|
2230
2782
|
const inferredServer = prServer ? 'happy-server' : prServerLight ? 'happy-server-light' : '';
|
|
2231
2783
|
const serverComponent = (serverFromArg || inferredServer || 'happy-server-light').trim();
|
|
@@ -2239,6 +2791,10 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2239
2791
|
throw new Error('[stack] pr: choose either --dev or --start (not both)');
|
|
2240
2792
|
}
|
|
2241
2793
|
|
|
2794
|
+
const wantsMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
2795
|
+
const wantsExpoTailscale = flags.has('--expo-tailscale');
|
|
2796
|
+
const background = flags.has('--background') || flags.has('--bg') || (kv.get('--background') ?? '').trim() === '1';
|
|
2797
|
+
|
|
2242
2798
|
const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
|
|
2243
2799
|
const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
|
|
2244
2800
|
const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
|
|
@@ -2367,14 +2923,22 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2367
2923
|
}
|
|
2368
2924
|
|
|
2369
2925
|
// 2) Checkout PR worktrees and pin them to the stack env file.
|
|
2370
|
-
const prSpecs =
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2926
|
+
const prSpecs = (
|
|
2927
|
+
happyMonorepoActive
|
|
2928
|
+
? [
|
|
2929
|
+
...(prHappy ? [{ component: 'happy', pr: prHappy }] : []),
|
|
2930
|
+
...(serverComponent === 'happy-server-light' && prServerLight ? [{ component: 'happy-server-light', pr: prServerLight }] : []),
|
|
2931
|
+
]
|
|
2932
|
+
: [
|
|
2933
|
+
...(prHappy ? [{ component: 'happy', pr: prHappy }] : []),
|
|
2934
|
+
...(prCli ? [{ component: 'happy-cli', pr: prCli }] : []),
|
|
2935
|
+
...(serverComponent === 'happy-server' && prServer ? [{ component: 'happy-server', pr: prServer }] : []),
|
|
2936
|
+
...(serverComponent === 'happy-server-light' && prServerLight ? [{ component: 'happy-server-light', pr: prServerLight }] : []),
|
|
2937
|
+
]
|
|
2938
|
+
).filter((x) => x.pr);
|
|
2376
2939
|
|
|
2377
2940
|
const worktrees = [];
|
|
2941
|
+
const stackEnvPath = resolveStackEnvPath(stackName).envPath;
|
|
2378
2942
|
for (const { component, pr } of prSpecs) {
|
|
2379
2943
|
progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
|
|
2380
2944
|
const out = await withStackEnv({
|
|
@@ -2385,7 +2949,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2385
2949
|
'pr',
|
|
2386
2950
|
component,
|
|
2387
2951
|
pr,
|
|
2388
|
-
`--remote=${
|
|
2952
|
+
...(remoteNameFromArg ? [`--remote=${remoteNameFromArg}`] : []),
|
|
2389
2953
|
'--use',
|
|
2390
2954
|
...(depsMode ? [`--deps=${depsMode}`] : []),
|
|
2391
2955
|
...(doUpdate ? ['--update'] : []),
|
|
@@ -2393,11 +2957,36 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2393
2957
|
'--json',
|
|
2394
2958
|
];
|
|
2395
2959
|
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
|
|
2396
|
-
|
|
2960
|
+
const parsed = stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
2961
|
+
|
|
2962
|
+
// Fail-closed invariant for PR stacks:
|
|
2963
|
+
// If you asked to pin a component to a PR checkout, it MUST be a worktree path under
|
|
2964
|
+
// the active workspace components dir (including sandbox workspace).
|
|
2965
|
+
if (parsed?.path && !isComponentWorktreePath({ rootDir, component, dir: parsed.path, env })) {
|
|
2966
|
+
const expectedRepoKey = parsed?.repoKey ? String(parsed.repoKey) : component;
|
|
2967
|
+
throw new Error(
|
|
2968
|
+
`[stack] pr: refusing to pin ${component} because the checked out path is not a worktree.\n` +
|
|
2969
|
+
`- expected under: ${join(getComponentsDir(rootDir, env), '.worktrees', expectedRepoKey)}/...\n` +
|
|
2970
|
+
`- actual: ${String(parsed.path ?? '').trim()}\n` +
|
|
2971
|
+
`Fix: this is a bug. Please re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
return parsed;
|
|
2397
2976
|
},
|
|
2398
2977
|
});
|
|
2399
|
-
if (
|
|
2978
|
+
if (out) {
|
|
2400
2979
|
worktrees.push(out);
|
|
2980
|
+
// Fail-closed invariant for PR stacks:
|
|
2981
|
+
// - if you asked to pin a component to a PR checkout, the stack env file MUST point at that exact worktree dir
|
|
2982
|
+
// before we start dev/start. Otherwise the stack can accidentally run the base checkout.
|
|
2983
|
+
//
|
|
2984
|
+
// We intentionally do NOT rely solely on `wt pr --use` for this; we make it explicit here.
|
|
2985
|
+
const key = componentDirEnvKey(component);
|
|
2986
|
+
await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key, value: out.path }] });
|
|
2987
|
+
}
|
|
2988
|
+
if (json) {
|
|
2989
|
+
// collected above
|
|
2401
2990
|
} else if (out) {
|
|
2402
2991
|
const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
|
|
2403
2992
|
const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
|
|
@@ -2414,6 +3003,70 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2414
3003
|
}
|
|
2415
3004
|
}
|
|
2416
3005
|
|
|
3006
|
+
// Monorepo shortcut:
|
|
3007
|
+
// If `--happy=<pr>` was provided and the local checkout is the slopus/happy monorepo, pin
|
|
3008
|
+
// happy-cli and (optionally) happy-server to that same worktree without fetching separate PRs.
|
|
3009
|
+
if (happyMonorepoActive && prHappy) {
|
|
3010
|
+
const happyWt = worktrees.find((w) => w?.component === 'happy');
|
|
3011
|
+
const happyPath = String(happyWt?.path ?? '').trim();
|
|
3012
|
+
const happyRoot = happyWt?.worktreeRoot ? resolve(String(happyWt.worktreeRoot)) : happyPath ? coerceHappyMonorepoRootFromPath(happyPath) : null;
|
|
3013
|
+
if (!happyRoot) {
|
|
3014
|
+
throw new Error('[stack] pr: expected happy monorepo worktree root but could not resolve it from the checked out path.');
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
const derive = (component) => {
|
|
3018
|
+
const sub = happyMonorepoSubdirForComponent(component);
|
|
3019
|
+
if (!sub) return null;
|
|
3020
|
+
return join(happyRoot, sub);
|
|
3021
|
+
};
|
|
3022
|
+
|
|
3023
|
+
const derivedComponents = [
|
|
3024
|
+
'happy-cli',
|
|
3025
|
+
...(serverComponent === 'happy-server' ? ['happy-server'] : []),
|
|
3026
|
+
];
|
|
3027
|
+
|
|
3028
|
+
for (const c of derivedComponents) {
|
|
3029
|
+
const p = derive(c);
|
|
3030
|
+
if (!p) continue;
|
|
3031
|
+
if (!isComponentWorktreePath({ rootDir, component: c, dir: p, env: process.env })) {
|
|
3032
|
+
throw new Error(`[stack] pr: refusing to pin ${c} because the derived path is not a worktree: ${p}`);
|
|
3033
|
+
}
|
|
3034
|
+
const key = componentDirEnvKey(c);
|
|
3035
|
+
await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key, value: p }] });
|
|
3036
|
+
worktrees.push({ component: c, path: p });
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
// Validate that all PR components are pinned correctly before starting.
|
|
3041
|
+
// This prevents "wrong daemon" / "wrong UI" errors that are otherwise extremely confusing in review-pr.
|
|
3042
|
+
if (prSpecs.length) {
|
|
3043
|
+
const afterRaw = await readExistingEnv(stackEnvPath);
|
|
3044
|
+
const afterEnv = parseEnvToObject(afterRaw);
|
|
3045
|
+
for (const wt of worktrees) {
|
|
3046
|
+
const key = componentDirEnvKey(wt.component);
|
|
3047
|
+
const val = (afterEnv[key] ?? '').toString().trim();
|
|
3048
|
+
const expected = resolve(String(wt.path ?? '').trim());
|
|
3049
|
+
const actual = val ? resolve(val) : '';
|
|
3050
|
+
if (!actual) {
|
|
3051
|
+
throw new Error(
|
|
3052
|
+
`[stack] pr: failed to pin ${wt.component} to the PR checkout.\n` +
|
|
3053
|
+
`- missing env key: ${key}\n` +
|
|
3054
|
+
`- expected: ${expected}\n` +
|
|
3055
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
3056
|
+
);
|
|
3057
|
+
}
|
|
3058
|
+
if (expected && actual !== expected) {
|
|
3059
|
+
throw new Error(
|
|
3060
|
+
`[stack] pr: stack is pinned to the wrong checkout for ${wt.component}.\n` +
|
|
3061
|
+
`- env key: ${key}\n` +
|
|
3062
|
+
`- expected: ${expected}\n` +
|
|
3063
|
+
`- actual: ${actual}\n` +
|
|
3064
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
3065
|
+
);
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
|
|
2417
3070
|
// 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
|
|
2418
3071
|
let auth = null;
|
|
2419
3072
|
if (seedAuth) {
|
|
@@ -2426,8 +3079,10 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2426
3079
|
...(authLink ? ['--link'] : []),
|
|
2427
3080
|
];
|
|
2428
3081
|
if (json) {
|
|
3082
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
2429
3083
|
auth = await withStackEnv({
|
|
2430
3084
|
stackName,
|
|
3085
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
2431
3086
|
fn: async ({ env }) => {
|
|
2432
3087
|
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
|
|
2433
3088
|
return stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
@@ -2442,12 +3097,20 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2442
3097
|
// 4) Optional: start dev / start.
|
|
2443
3098
|
if (wantsDev) {
|
|
2444
3099
|
progress(`[stack] pr: ${stackName}: starting dev...`);
|
|
2445
|
-
const args =
|
|
2446
|
-
|
|
3100
|
+
const args = [
|
|
3101
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
3102
|
+
...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
|
|
3103
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
3104
|
+
];
|
|
3105
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
|
|
2447
3106
|
} else if (wantsStart) {
|
|
2448
3107
|
progress(`[stack] pr: ${stackName}: starting...`);
|
|
2449
|
-
const args =
|
|
2450
|
-
|
|
3108
|
+
const args = [
|
|
3109
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
3110
|
+
...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
|
|
3111
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
3112
|
+
];
|
|
3113
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
|
|
2451
3114
|
}
|
|
2452
3115
|
|
|
2453
3116
|
const info = await cmdInfoInternal({ rootDir, stackName });
|
|
@@ -2500,10 +3163,15 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2500
3163
|
runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
|
|
2501
3164
|
? Number(runtimeState.expo.webPort)
|
|
2502
3165
|
: null;
|
|
3166
|
+
const mobilePort =
|
|
3167
|
+
runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.mobilePort) > 0
|
|
3168
|
+
? Number(runtimeState.expo.mobilePort)
|
|
3169
|
+
: null;
|
|
2503
3170
|
|
|
2504
3171
|
const host = resolveLocalhostHost({ stackMode: true, stackName });
|
|
2505
3172
|
const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
2506
3173
|
const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
|
|
3174
|
+
const mobileUrl = mobilePort ? await preferStackLocalhostUrl(`http://localhost:${mobilePort}`, { stackName }) : null;
|
|
2507
3175
|
|
|
2508
3176
|
const componentSpecs = [
|
|
2509
3177
|
{ component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
|
|
@@ -2546,11 +3214,13 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2546
3214
|
host,
|
|
2547
3215
|
internalServerUrl,
|
|
2548
3216
|
uiUrl,
|
|
3217
|
+
mobileUrl,
|
|
2549
3218
|
},
|
|
2550
3219
|
ports: {
|
|
2551
3220
|
server: serverPort,
|
|
2552
3221
|
backend: backendPort,
|
|
2553
3222
|
ui: uiPort,
|
|
3223
|
+
mobile: mobilePort,
|
|
2554
3224
|
},
|
|
2555
3225
|
components,
|
|
2556
3226
|
};
|
|
@@ -2609,19 +3279,25 @@ async function main() {
|
|
|
2609
3279
|
'list',
|
|
2610
3280
|
'migrate',
|
|
2611
3281
|
'audit',
|
|
2612
|
-
|
|
3282
|
+
'archive',
|
|
3283
|
+
'duplicate',
|
|
2613
3284
|
'info',
|
|
2614
3285
|
'pr',
|
|
2615
3286
|
'create-dev-auth-seed',
|
|
3287
|
+
'happy',
|
|
3288
|
+
'env',
|
|
2616
3289
|
'auth',
|
|
2617
3290
|
'dev',
|
|
2618
3291
|
'start',
|
|
2619
3292
|
'build',
|
|
3293
|
+
'review',
|
|
2620
3294
|
'typecheck',
|
|
2621
3295
|
'lint',
|
|
2622
3296
|
'test',
|
|
2623
3297
|
'doctor',
|
|
2624
3298
|
'mobile',
|
|
3299
|
+
'mobile:install',
|
|
3300
|
+
'mobile-dev-client',
|
|
2625
3301
|
'resume',
|
|
2626
3302
|
'stop',
|
|
2627
3303
|
'code',
|
|
@@ -2635,24 +3311,30 @@ async function main() {
|
|
|
2635
3311
|
},
|
|
2636
3312
|
text: [
|
|
2637
3313
|
'[stack] usage:',
|
|
2638
|
-
' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--
|
|
3314
|
+
' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--interactive] [--copy-auth-from=<stack>] [--no-copy-auth] [--force-port] [--json]',
|
|
2639
3315
|
' happys stack edit <name> --interactive [--json]',
|
|
2640
3316
|
' happys stack list [--json]',
|
|
2641
3317
|
' happys stack migrate [--json] # copy legacy env files from ~/.happy/local/stacks/* -> ~/.happy/stacks/*',
|
|
2642
3318
|
' happys stack audit [--fix] [--fix-main] [--fix-ports] [--fix-workspace] [--fix-paths] [--unpin-ports] [--unpin-ports-except=stack1,stack2] [--json]',
|
|
3319
|
+
' happys stack archive <name> [--dry-run] [--date=YYYY-MM-DD] [--json]',
|
|
2643
3320
|
' happys stack duplicate <from> <to> [--duplicate-worktrees] [--deps=none|link|install|link-or-install] [--json]',
|
|
2644
3321
|
' happys stack info <name> [--json]',
|
|
2645
|
-
' happys stack pr <name> --happy=<pr-url|number> [--happy-
|
|
3322
|
+
' happys stack pr <name> --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
|
|
2646
3323
|
' happys stack create-dev-auth-seed [name] [--server=happy-server|happy-server-light] [--non-interactive] [--json]',
|
|
3324
|
+
' happys stack happy <name> [-- ...]',
|
|
3325
|
+
' happys stack env <name> set KEY=VALUE [KEY2=VALUE2...] | unset KEY [KEY2...] | get KEY | list | path [--json]',
|
|
2647
3326
|
' happys stack auth <name> status|login|copy-from [--json]',
|
|
2648
3327
|
' happys stack dev <name> [-- ...]',
|
|
2649
3328
|
' happys stack start <name> [-- ...]',
|
|
2650
3329
|
' happys stack build <name> [-- ...]',
|
|
3330
|
+
' happys stack review <name> [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--chunks|--no-chunks] [--chunking=auto|head-slice|commit-window] [--chunk-max-files=N] [--json]',
|
|
2651
3331
|
' happys stack typecheck <name> [component...] [--json]',
|
|
2652
3332
|
' happys stack lint <name> [component...] [--json]',
|
|
2653
3333
|
' happys stack test <name> [component...] [--json]',
|
|
2654
3334
|
' happys stack doctor <name> [-- ...]',
|
|
2655
3335
|
' happys stack mobile <name> [-- ...]',
|
|
3336
|
+
' happys stack mobile:install <name> [--name="Happy (exp1)"] [--device=...] [--json]',
|
|
3337
|
+
' happys stack mobile-dev-client <name> --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
|
|
2656
3338
|
' happys stack resume <name> <sessionId...> [--json]',
|
|
2657
3339
|
' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
|
|
2658
3340
|
' happys stack code <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
|
|
@@ -2765,6 +3447,15 @@ async function main() {
|
|
|
2765
3447
|
'example:',
|
|
2766
3448
|
' happys stack srv exp1 -- status',
|
|
2767
3449
|
]
|
|
3450
|
+
: cmd === 'env'
|
|
3451
|
+
? [
|
|
3452
|
+
'[stack] usage:',
|
|
3453
|
+
' happys stack env <name> set KEY=VALUE [KEY2=VALUE2...]',
|
|
3454
|
+
' happys stack env <name> unset KEY [KEY2...]',
|
|
3455
|
+
' happys stack env <name> get KEY',
|
|
3456
|
+
' happys stack env <name> list',
|
|
3457
|
+
' happys stack env <name> path',
|
|
3458
|
+
]
|
|
2768
3459
|
: cmd.startsWith('tailscale:')
|
|
2769
3460
|
? [
|
|
2770
3461
|
'[stack] usage:',
|
|
@@ -2785,12 +3476,51 @@ async function main() {
|
|
|
2785
3476
|
// Remaining args after "<cmd> <name>"
|
|
2786
3477
|
const passthrough = argv.slice(2);
|
|
2787
3478
|
|
|
3479
|
+
if (cmd === 'archive') {
|
|
3480
|
+
const res = await cmdArchiveStack({ rootDir, argv, stackName });
|
|
3481
|
+
if (json) {
|
|
3482
|
+
printResult({ json, data: res });
|
|
3483
|
+
} else if (res.dryRun) {
|
|
3484
|
+
console.log(`[stack] would archive "${stackName}" -> ${res.archivedStackDir} (dry-run)`);
|
|
3485
|
+
} else {
|
|
3486
|
+
console.log(`[stack] archived "${stackName}" -> ${res.archivedStackDir}`);
|
|
3487
|
+
}
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
if (cmd === 'env') {
|
|
3492
|
+
const hasPositional = passthrough.some((a) => !a.startsWith('-'));
|
|
3493
|
+
const envArgv = hasPositional ? passthrough : ['list', ...passthrough];
|
|
3494
|
+
// Forward to scripts/env.mjs under the stack env.
|
|
3495
|
+
// This keeps stack env editing behavior unified with `happys env ...`.
|
|
3496
|
+
await withStackEnv({
|
|
3497
|
+
stackName,
|
|
3498
|
+
fn: async ({ env }) => {
|
|
3499
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'env.mjs'), ...envArgv], { cwd: rootDir, env });
|
|
3500
|
+
},
|
|
3501
|
+
});
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
if (cmd === 'happy') {
|
|
3505
|
+
const args = passthrough[0] === '--' ? passthrough.slice(1) : passthrough;
|
|
3506
|
+
await withStackEnv({
|
|
3507
|
+
stackName,
|
|
3508
|
+
fn: async ({ env }) => {
|
|
3509
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'happy.mjs'), ...args], { cwd: rootDir, env });
|
|
3510
|
+
},
|
|
3511
|
+
});
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
2788
3514
|
if (cmd === 'dev') {
|
|
2789
|
-
|
|
3515
|
+
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
3516
|
+
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|
|
3517
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
|
|
2790
3518
|
return;
|
|
2791
3519
|
}
|
|
2792
3520
|
if (cmd === 'start') {
|
|
2793
|
-
|
|
3521
|
+
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
3522
|
+
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|
|
3523
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
|
|
2794
3524
|
return;
|
|
2795
3525
|
}
|
|
2796
3526
|
if (cmd === 'build') {
|
|
@@ -2817,6 +3547,12 @@ async function main() {
|
|
|
2817
3547
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
|
|
2818
3548
|
return;
|
|
2819
3549
|
}
|
|
3550
|
+
if (cmd === 'review') {
|
|
3551
|
+
const { kv } = parseArgs(passthrough);
|
|
3552
|
+
const overrides = resolveTransientComponentOverrides({ rootDir, kv });
|
|
3553
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'review.mjs', args: passthrough, extraEnv: overrides });
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
2820
3556
|
if (cmd === 'doctor') {
|
|
2821
3557
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
|
|
2822
3558
|
return;
|
|
@@ -2825,6 +3561,62 @@ async function main() {
|
|
|
2825
3561
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
|
|
2826
3562
|
return;
|
|
2827
3563
|
}
|
|
3564
|
+
if (cmd === 'mobile-dev-client') {
|
|
3565
|
+
// Stack-scoped wrapper so the dev-client can be built from the stack's active happy checkout/worktree.
|
|
3566
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile_dev_client.mjs', args: passthrough });
|
|
3567
|
+
return;
|
|
3568
|
+
}
|
|
3569
|
+
if (cmd === 'mobile:install') {
|
|
3570
|
+
const { flags: mFlags, kv: mKv } = parseArgs(passthrough);
|
|
3571
|
+
const device = (mKv.get('--device') ?? '').toString();
|
|
3572
|
+
const name = (mKv.get('--name') ?? mKv.get('--app-name') ?? '').toString().trim();
|
|
3573
|
+
const jsonOut = wantsJson(passthrough, { flags: mFlags }) || json;
|
|
3574
|
+
|
|
3575
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
3576
|
+
const existingRaw = await readExistingEnv(envPath);
|
|
3577
|
+
const existing = parseEnvToObject(existingRaw);
|
|
3578
|
+
|
|
3579
|
+
const priorName =
|
|
3580
|
+
(existing.HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME ?? existing.HAPPY_LOCAL_MOBILE_RELEASE_IOS_APP_NAME ?? '').toString().trim();
|
|
3581
|
+
const identity = defaultStackReleaseIdentity({
|
|
3582
|
+
stackName,
|
|
3583
|
+
user: process.env.USER ?? process.env.USERNAME ?? 'user',
|
|
3584
|
+
appName: name || priorName || null,
|
|
3585
|
+
});
|
|
3586
|
+
|
|
3587
|
+
// Persist the chosen identity so re-installs are stable and user-friendly.
|
|
3588
|
+
await ensureEnvFileUpdated({
|
|
3589
|
+
envPath,
|
|
3590
|
+
updates: [
|
|
3591
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME', value: identity.iosAppName },
|
|
3592
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_BUNDLE_ID', value: identity.iosBundleId },
|
|
3593
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_SCHEME', value: identity.scheme },
|
|
3594
|
+
],
|
|
3595
|
+
});
|
|
3596
|
+
|
|
3597
|
+
// Install a per-stack release-configured app (isolated container) without starting Metro.
|
|
3598
|
+
const args = [
|
|
3599
|
+
`--app-env=production`,
|
|
3600
|
+
`--ios-app-name=${identity.iosAppName}`,
|
|
3601
|
+
`--ios-bundle-id=${identity.iosBundleId}`,
|
|
3602
|
+
`--scheme=${identity.scheme}`,
|
|
3603
|
+
'--prebuild',
|
|
3604
|
+
'--run-ios',
|
|
3605
|
+
'--configuration=Release',
|
|
3606
|
+
'--no-metro',
|
|
3607
|
+
...(device ? [`--device=${device}`] : []),
|
|
3608
|
+
];
|
|
3609
|
+
|
|
3610
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args });
|
|
3611
|
+
|
|
3612
|
+
if (jsonOut) {
|
|
3613
|
+
printResult({
|
|
3614
|
+
json: true,
|
|
3615
|
+
data: { ok: true, stackName, installed: true, identity },
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
2828
3620
|
if (cmd === 'resume') {
|
|
2829
3621
|
const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
|
|
2830
3622
|
if (sessionIds.length === 0) {
|
|
@@ -2841,7 +3633,9 @@ async function main() {
|
|
|
2841
3633
|
const out = await withStackEnv({
|
|
2842
3634
|
stackName,
|
|
2843
3635
|
fn: async ({ env }) => {
|
|
2844
|
-
|
|
3636
|
+
// IMPORTANT: use the stack's pinned happy-cli checkout if set.
|
|
3637
|
+
// Do not read component dirs from this process's `process.env` (withStackEnv does not mutate it).
|
|
3638
|
+
const cliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() || getComponentDir(rootDir, 'happy-cli');
|
|
2845
3639
|
const happyBin = join(cliDir, 'bin', 'happy.mjs');
|
|
2846
3640
|
// Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
|
|
2847
3641
|
return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });
|