happy-stacks 0.4.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 +64 -33
- package/bin/happys.mjs +44 -1
- 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 +1 -2
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +1 -1
- package/scripts/auth.mjs +21 -3
- package/scripts/build.mjs +1 -1
- package/scripts/dev.mjs +20 -7
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +2 -2
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +5 -2
- package/scripts/install.mjs +99 -57
- package/scripts/migrate.mjs +3 -12
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/review.mjs +715 -24
- package/scripts/review_pr.mjs +5 -20
- package/scripts/run.mjs +21 -15
- package/scripts/setup.mjs +147 -25
- package/scripts/setup_pr.mjs +19 -28
- package/scripts/stack.mjs +493 -157
- 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/tui.mjs +88 -2
- package/scripts/utils/cli/cli_registry.mjs +20 -5
- package/scripts/utils/cli/cwd_scope.mjs +56 -2
- package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
- package/scripts/utils/cli/prereqs.mjs +8 -5
- package/scripts/utils/cli/prereqs.test.mjs +34 -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 +14 -1
- package/scripts/utils/dev/expo_dev.mjs +188 -4
- package/scripts/utils/dev/server.mjs +21 -17
- 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/git/worktrees.mjs +63 -12
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/paths/paths.mjs +118 -3
- 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/pm.mjs +113 -16
- 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 +68 -10
- package/scripts/utils/proc/proc.test.mjs +77 -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 +56 -14
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +32 -22
- 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/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -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/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/stack/editor_workspace.mjs +4 -4
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/startup.mjs +113 -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/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +627 -137
- 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/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, open, 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';
|
|
@@ -11,12 +11,14 @@ import { parseArgs } from './utils/cli/args.mjs';
|
|
|
11
11
|
import { killProcessTree, run, runCapture } from './utils/proc/proc.mjs';
|
|
12
12
|
import {
|
|
13
13
|
componentDirEnvKey,
|
|
14
|
+
coerceHappyMonorepoRootFromPath,
|
|
14
15
|
getComponentDir,
|
|
15
16
|
getComponentsDir,
|
|
16
17
|
getHappyStacksHomeDir,
|
|
17
18
|
getLegacyStorageRoot,
|
|
18
19
|
getRootDir,
|
|
19
20
|
getStacksStorageRoot,
|
|
21
|
+
happyMonorepoSubdirForComponent,
|
|
20
22
|
resolveStackEnvPath,
|
|
21
23
|
} from './utils/paths/paths.mjs';
|
|
22
24
|
import { isTcpPortFree, listListenPids, pickNextFreeTcpPort } from './utils/net/ports.mjs';
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
resolveComponentSpecToDir,
|
|
29
31
|
worktreeSpecFromDir,
|
|
30
32
|
} from './utils/git/worktrees.mjs';
|
|
31
|
-
import { isTty, prompt,
|
|
33
|
+
import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
32
34
|
import { parseEnvToObject } from './utils/env/dotenv.mjs';
|
|
33
35
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
34
36
|
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
@@ -66,6 +68,7 @@ import { sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
|
66
68
|
import { isCursorInstalled, openWorkspaceInEditor, writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
|
|
67
69
|
import { readLastLines } from './utils/fs/tail.mjs';
|
|
68
70
|
import { defaultStackReleaseIdentity } from './utils/mobile/identifiers.mjs';
|
|
71
|
+
import { interactiveEdit, interactiveNew } from './utils/stack/interactive_stack_config.mjs';
|
|
69
72
|
|
|
70
73
|
function stackNameFromArg(positionals, idx) {
|
|
71
74
|
const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
|
|
@@ -242,18 +245,48 @@ function stringifyEnv(env) {
|
|
|
242
245
|
const readExistingEnv = readTextOrEmpty;
|
|
243
246
|
|
|
244
247
|
function resolveDefaultComponentDirs({ rootDir }) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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) {
|
|
248
256
|
const embedded = join(rootDir, 'components', name);
|
|
249
257
|
const workspace = join(getComponentsDir(rootDir), name);
|
|
250
258
|
// CRITICAL:
|
|
251
259
|
// In sandbox mode, never point stacks at the repo's embedded `components/*` checkouts.
|
|
252
260
|
// Sandboxes must use the sandbox workspace clones (HAPPY_STACKS_WORKSPACE_DIR/components/*),
|
|
253
261
|
// otherwise worktrees/branches collide with the user's real machine state.
|
|
254
|
-
|
|
255
|
-
out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
|
|
262
|
+
return !isSandboxed() && existsSync(embedded) ? embedded : workspace;
|
|
256
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');
|
|
257
290
|
return out;
|
|
258
291
|
}
|
|
259
292
|
|
|
@@ -401,110 +434,6 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
|
|
|
401
434
|
return await fn({ env, envPath, stackEnv, runtimeStatePath, runtimeState });
|
|
402
435
|
}
|
|
403
436
|
|
|
404
|
-
async function interactiveNew({ rootDir, rl, defaults }) {
|
|
405
|
-
const out = { ...defaults };
|
|
406
|
-
|
|
407
|
-
if (!out.stackName) {
|
|
408
|
-
out.stackName = (await rl.question('Stack name: ')).trim();
|
|
409
|
-
}
|
|
410
|
-
if (!out.stackName) {
|
|
411
|
-
throw new Error('[stack] stack name is required');
|
|
412
|
-
}
|
|
413
|
-
if (out.stackName === 'main') {
|
|
414
|
-
throw new Error('[stack] stack name \"main\" is reserved (use the default stack without creating it)');
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Server component selection
|
|
418
|
-
if (!out.serverComponent) {
|
|
419
|
-
const server = (await rl.question('Server component [happy-server-light|happy-server] (default: happy-server-light): ')).trim();
|
|
420
|
-
out.serverComponent = server || 'happy-server-light';
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Port
|
|
424
|
-
if (!out.port) {
|
|
425
|
-
const want = (await rl.question('Port (empty = ephemeral): ')).trim();
|
|
426
|
-
out.port = want ? Number(want) : null;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Remote for creating new worktrees (used by all "create new worktree" choices)
|
|
430
|
-
if (!out.createRemote) {
|
|
431
|
-
out.createRemote = await prompt(rl, 'Git remote for creating new worktrees (default: upstream): ', { defaultValue: 'upstream' });
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Component selections
|
|
435
|
-
for (const c of ['happy', 'happy-cli']) {
|
|
436
|
-
if (out.components[c] != null) continue;
|
|
437
|
-
out.components[c] = await promptWorktreeSource({
|
|
438
|
-
rl,
|
|
439
|
-
rootDir,
|
|
440
|
-
component: c,
|
|
441
|
-
stackName: out.stackName,
|
|
442
|
-
createRemote: out.createRemote,
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Server worktree selection (optional; only for the chosen server component)
|
|
447
|
-
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
448
|
-
if (out.components[serverComponent] == null) {
|
|
449
|
-
out.components[serverComponent] = await promptWorktreeSource({
|
|
450
|
-
rl,
|
|
451
|
-
rootDir,
|
|
452
|
-
component: serverComponent,
|
|
453
|
-
stackName: out.stackName,
|
|
454
|
-
createRemote: out.createRemote,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
return out;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }) {
|
|
462
|
-
const out = { ...defaults, stackName };
|
|
463
|
-
|
|
464
|
-
// Server component selection
|
|
465
|
-
const currentServer = existingEnv.HAPPY_STACKS_SERVER_COMPONENT ?? existingEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '';
|
|
466
|
-
const server = await prompt(
|
|
467
|
-
rl,
|
|
468
|
-
`Server component [happy-server-light|happy-server] (default: ${currentServer || 'happy-server-light'}): `,
|
|
469
|
-
{ defaultValue: currentServer || 'happy-server-light' }
|
|
470
|
-
);
|
|
471
|
-
out.serverComponent = server || 'happy-server-light';
|
|
472
|
-
|
|
473
|
-
// Port
|
|
474
|
-
const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
|
|
475
|
-
const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'ephemeral'}; type 'ephemeral' to unpin): `, { defaultValue: '' });
|
|
476
|
-
const wantTrimmed = wantPort.trim().toLowerCase();
|
|
477
|
-
out.port = wantTrimmed === 'ephemeral' ? null : wantPort ? Number(wantPort) : (currentPort ? Number(currentPort) : null);
|
|
478
|
-
|
|
479
|
-
// Remote for creating new worktrees
|
|
480
|
-
const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
|
|
481
|
-
out.createRemote = await prompt(rl, `Git remote for creating new worktrees (default: ${currentRemote || 'upstream'}): `, {
|
|
482
|
-
defaultValue: currentRemote || 'upstream',
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
// Worktree selections
|
|
486
|
-
for (const c of ['happy', 'happy-cli']) {
|
|
487
|
-
out.components[c] = await promptWorktreeSource({
|
|
488
|
-
rl,
|
|
489
|
-
rootDir,
|
|
490
|
-
component: c,
|
|
491
|
-
stackName,
|
|
492
|
-
createRemote: out.createRemote,
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
497
|
-
out.components[serverComponent] = await promptWorktreeSource({
|
|
498
|
-
rl,
|
|
499
|
-
rootDir,
|
|
500
|
-
component: serverComponent,
|
|
501
|
-
stackName,
|
|
502
|
-
createRemote: out.createRemote,
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
return out;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
437
|
async function cmdNew({ rootDir, argv, emit = true }) {
|
|
509
438
|
const { flags, kv } = parseArgs(argv);
|
|
510
439
|
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
@@ -551,7 +480,7 @@ async function cmdNew({ rootDir, argv, emit = true }) {
|
|
|
551
480
|
if (!stackName) {
|
|
552
481
|
throw new Error(
|
|
553
482
|
'[stack] usage: happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] ' +
|
|
554
|
-
'[--happy=default|<owner/...>|<path>] [--happy-
|
|
483
|
+
'[--happy=default|<owner/...>|<path>] [--happy-server-light=...] ' +
|
|
555
484
|
'[--copy-auth-from=<stack|legacy>] [--link-auth] [--no-copy-auth] [--interactive] [--force-port]'
|
|
556
485
|
);
|
|
557
486
|
}
|
|
@@ -672,24 +601,88 @@ async function cmdNew({ rootDir, argv, emit = true }) {
|
|
|
672
601
|
}
|
|
673
602
|
}
|
|
674
603
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
+
}
|
|
683
664
|
}
|
|
684
665
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
+
}
|
|
693
686
|
}
|
|
694
687
|
|
|
695
688
|
// Server component directory override (optional)
|
|
@@ -708,13 +701,15 @@ async function cmdNew({ rootDir, argv, emit = true }) {
|
|
|
708
701
|
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = resolve(rootDir, dir);
|
|
709
702
|
}
|
|
710
703
|
} else if (serverComponent === 'happy-server') {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
+
}
|
|
718
713
|
}
|
|
719
714
|
}
|
|
720
715
|
|
|
@@ -896,11 +891,83 @@ async function cmdEdit({ rootDir, argv }) {
|
|
|
896
891
|
}
|
|
897
892
|
};
|
|
898
893
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
+
}
|
|
903
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') {
|
|
904
971
|
await applyComponent('happy-server-light', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', config.components['happy-server-light']);
|
|
905
972
|
}
|
|
906
973
|
|
|
@@ -1448,7 +1515,21 @@ async function cmdWt({ rootDir, stackName, args }) {
|
|
|
1448
1515
|
// Forward to scripts/worktrees.mjs under the stack env.
|
|
1449
1516
|
// This makes `happys stack wt <name> -- ...` behave exactly like `happys wt ...`,
|
|
1450
1517
|
// but read/write the stack env file (HAPPY_STACKS_ENV_FILE / legacy: HAPPY_LOCAL_ENV_FILE) instead of repo env.local.
|
|
1451
|
-
|
|
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
|
+
|
|
1452
1533
|
await withStackEnv({
|
|
1453
1534
|
stackName,
|
|
1454
1535
|
fn: async ({ env }) => {
|
|
@@ -2310,6 +2391,150 @@ async function readStackEnvObject(stackName) {
|
|
|
2310
2391
|
return { envPath, env };
|
|
2311
2392
|
}
|
|
2312
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
|
+
|
|
2313
2538
|
function envKeyForComponentDir({ serverComponent, component }) {
|
|
2314
2539
|
if (component === 'happy') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY';
|
|
2315
2540
|
if (component === 'happy-cli') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI';
|
|
@@ -2470,26 +2695,26 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2470
2695
|
json,
|
|
2471
2696
|
data: {
|
|
2472
2697
|
usage:
|
|
2473
|
-
'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...>]',
|
|
2474
2699
|
},
|
|
2475
2700
|
text: [
|
|
2476
2701
|
'[stack] usage:',
|
|
2477
|
-
' happys stack pr <name> --happy=<pr-url|number> [--happy-
|
|
2702
|
+
' happys stack pr <name> --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start]',
|
|
2478
2703
|
' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
|
|
2479
2704
|
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
|
|
2480
|
-
' [--mobile]
|
|
2705
|
+
' [--mobile] # also start Expo dev-client Metro for mobile',
|
|
2706
|
+
' [--expo-tailscale] # forward Expo to Tailscale interface for remote access',
|
|
2481
2707
|
' [--json] [-- <stack dev/start args...>]',
|
|
2482
2708
|
'',
|
|
2483
2709
|
'examples:',
|
|
2484
2710
|
' # Create stack + check out PRs + start dev UI',
|
|
2485
2711
|
' happys stack pr pr123 \\',
|
|
2486
2712
|
' --happy=https://github.com/slopus/happy/pull/123 \\',
|
|
2487
|
-
' --happy-cli=https://github.com/slopus/happy-cli/pull/456 \\',
|
|
2488
2713
|
' --seed-auth --copy-auth-from=dev-auth \\',
|
|
2489
2714
|
' --dev',
|
|
2490
2715
|
'',
|
|
2491
2716
|
' # Use numeric PR refs (remote defaults to upstream)',
|
|
2492
|
-
' happys stack pr pr123 --happy=123 --
|
|
2717
|
+
' happys stack pr pr123 --happy=123 --seed-auth --copy-auth-from=dev-auth --dev',
|
|
2493
2718
|
'',
|
|
2494
2719
|
' # Reuse an existing non-stacks Happy install for auth seeding',
|
|
2495
2720
|
' (deprecated) legacy ~/.happy is not supported for reliable seeding',
|
|
@@ -2537,6 +2762,22 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2537
2762
|
throw new Error('[stack] pr: cannot specify both --happy-server and --happy-server-light');
|
|
2538
2763
|
}
|
|
2539
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
|
+
|
|
2540
2781
|
const serverFromArg = (kv.get('--server') ?? '').trim();
|
|
2541
2782
|
const inferredServer = prServer ? 'happy-server' : prServerLight ? 'happy-server-light' : '';
|
|
2542
2783
|
const serverComponent = (serverFromArg || inferredServer || 'happy-server-light').trim();
|
|
@@ -2551,6 +2792,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2551
2792
|
}
|
|
2552
2793
|
|
|
2553
2794
|
const wantsMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
2795
|
+
const wantsExpoTailscale = flags.has('--expo-tailscale');
|
|
2554
2796
|
const background = flags.has('--background') || flags.has('--bg') || (kv.get('--background') ?? '').trim() === '1';
|
|
2555
2797
|
|
|
2556
2798
|
const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
|
|
@@ -2681,12 +2923,19 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2681
2923
|
}
|
|
2682
2924
|
|
|
2683
2925
|
// 2) Checkout PR worktrees and pin them to the stack env file.
|
|
2684
|
-
const prSpecs =
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
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);
|
|
2690
2939
|
|
|
2691
2940
|
const worktrees = [];
|
|
2692
2941
|
const stackEnvPath = resolveStackEnvPath(stackName).envPath;
|
|
@@ -2714,9 +2963,10 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2714
2963
|
// If you asked to pin a component to a PR checkout, it MUST be a worktree path under
|
|
2715
2964
|
// the active workspace components dir (including sandbox workspace).
|
|
2716
2965
|
if (parsed?.path && !isComponentWorktreePath({ rootDir, component, dir: parsed.path, env })) {
|
|
2966
|
+
const expectedRepoKey = parsed?.repoKey ? String(parsed.repoKey) : component;
|
|
2717
2967
|
throw new Error(
|
|
2718
2968
|
`[stack] pr: refusing to pin ${component} because the checked out path is not a worktree.\n` +
|
|
2719
|
-
`- expected under: ${join(getComponentsDir(rootDir, env), '.worktrees',
|
|
2969
|
+
`- expected under: ${join(getComponentsDir(rootDir, env), '.worktrees', expectedRepoKey)}/...\n` +
|
|
2720
2970
|
`- actual: ${String(parsed.path ?? '').trim()}\n` +
|
|
2721
2971
|
`Fix: this is a bug. Please re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2722
2972
|
);
|
|
@@ -2753,6 +3003,40 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2753
3003
|
}
|
|
2754
3004
|
}
|
|
2755
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
|
+
|
|
2756
3040
|
// Validate that all PR components are pinned correctly before starting.
|
|
2757
3041
|
// This prevents "wrong daemon" / "wrong UI" errors that are otherwise extremely confusing in review-pr.
|
|
2758
3042
|
if (prSpecs.length) {
|
|
@@ -2815,6 +3099,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2815
3099
|
progress(`[stack] pr: ${stackName}: starting dev...`);
|
|
2816
3100
|
const args = [
|
|
2817
3101
|
...(wantsMobile ? ['--mobile'] : []),
|
|
3102
|
+
...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
|
|
2818
3103
|
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
2819
3104
|
];
|
|
2820
3105
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
|
|
@@ -2822,6 +3107,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2822
3107
|
progress(`[stack] pr: ${stackName}: starting...`);
|
|
2823
3108
|
const args = [
|
|
2824
3109
|
...(wantsMobile ? ['--mobile'] : []),
|
|
3110
|
+
...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
|
|
2825
3111
|
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
2826
3112
|
];
|
|
2827
3113
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
|
|
@@ -2993,10 +3279,13 @@ async function main() {
|
|
|
2993
3279
|
'list',
|
|
2994
3280
|
'migrate',
|
|
2995
3281
|
'audit',
|
|
2996
|
-
|
|
3282
|
+
'archive',
|
|
3283
|
+
'duplicate',
|
|
2997
3284
|
'info',
|
|
2998
3285
|
'pr',
|
|
2999
3286
|
'create-dev-auth-seed',
|
|
3287
|
+
'happy',
|
|
3288
|
+
'env',
|
|
3000
3289
|
'auth',
|
|
3001
3290
|
'dev',
|
|
3002
3291
|
'start',
|
|
@@ -3022,20 +3311,23 @@ async function main() {
|
|
|
3022
3311
|
},
|
|
3023
3312
|
text: [
|
|
3024
3313
|
'[stack] usage:',
|
|
3025
|
-
' 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]',
|
|
3026
3315
|
' happys stack edit <name> --interactive [--json]',
|
|
3027
3316
|
' happys stack list [--json]',
|
|
3028
3317
|
' happys stack migrate [--json] # copy legacy env files from ~/.happy/local/stacks/* -> ~/.happy/stacks/*',
|
|
3029
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]',
|
|
3030
3320
|
' happys stack duplicate <from> <to> [--duplicate-worktrees] [--deps=none|link|install|link-or-install] [--json]',
|
|
3031
3321
|
' happys stack info <name> [--json]',
|
|
3032
|
-
' 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] [-- ...]',
|
|
3033
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]',
|
|
3034
3326
|
' happys stack auth <name> status|login|copy-from [--json]',
|
|
3035
3327
|
' happys stack dev <name> [-- ...]',
|
|
3036
3328
|
' happys stack start <name> [-- ...]',
|
|
3037
3329
|
' happys stack build <name> [-- ...]',
|
|
3038
|
-
' happys stack review <name> [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--json]',
|
|
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]',
|
|
3039
3331
|
' happys stack typecheck <name> [component...] [--json]',
|
|
3040
3332
|
' happys stack lint <name> [component...] [--json]',
|
|
3041
3333
|
' happys stack test <name> [component...] [--json]',
|
|
@@ -3155,6 +3447,15 @@ async function main() {
|
|
|
3155
3447
|
'example:',
|
|
3156
3448
|
' happys stack srv exp1 -- status',
|
|
3157
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
|
+
]
|
|
3158
3459
|
: cmd.startsWith('tailscale:')
|
|
3159
3460
|
? [
|
|
3160
3461
|
'[stack] usage:',
|
|
@@ -3175,6 +3476,41 @@ async function main() {
|
|
|
3175
3476
|
// Remaining args after "<cmd> <name>"
|
|
3176
3477
|
const passthrough = argv.slice(2);
|
|
3177
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
|
+
}
|
|
3178
3514
|
if (cmd === 'dev') {
|
|
3179
3515
|
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
3180
3516
|
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|