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/dev.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { resolveStackContext } from './utils/stack/context.mjs';
|
|
|
17
17
|
import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
|
|
18
18
|
import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev/daemon.mjs';
|
|
19
19
|
import { startDevServer, watchDevServerAndRestart } from './utils/dev/server.mjs';
|
|
20
|
-
import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
|
|
20
|
+
import { ensureDevExpoServer, resolveExpoTailscaleEnabled } from './utils/dev/expo_dev.mjs';
|
|
21
21
|
import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
|
|
22
22
|
import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
23
23
|
import { waitForHttpOk } from './utils/server/server.mjs';
|
|
@@ -41,18 +41,22 @@ async function main() {
|
|
|
41
41
|
if (wantsHelp(argv, { flags })) {
|
|
42
42
|
printResult({
|
|
43
43
|
json,
|
|
44
|
-
data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser', '--mobile'], json: true },
|
|
44
|
+
data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser', '--mobile', '--expo-tailscale'], json: true },
|
|
45
45
|
text: [
|
|
46
46
|
'[dev] usage:',
|
|
47
47
|
' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
|
|
48
|
-
' happys dev --watch
|
|
49
|
-
' happys dev --no-watch
|
|
50
|
-
' happys dev --no-browser
|
|
51
|
-
' happys dev --mobile
|
|
48
|
+
' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
|
|
49
|
+
' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
|
|
50
|
+
' happys dev --no-browser # do not open the UI in your browser automatically',
|
|
51
|
+
' happys dev --mobile # also start Expo dev-client Metro for mobile',
|
|
52
|
+
' happys dev --expo-tailscale # forward Expo to Tailscale interface for remote access',
|
|
52
53
|
' note: --json prints the resolved config (dry-run) and exits.',
|
|
53
54
|
'',
|
|
54
55
|
'note:',
|
|
55
56
|
' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
|
|
57
|
+
'',
|
|
58
|
+
'env:',
|
|
59
|
+
' HAPPY_STACKS_EXPO_TAILSCALE=1 # enable Expo Tailscale forwarding via env var',
|
|
56
60
|
].join('\n'),
|
|
57
61
|
});
|
|
58
62
|
return;
|
|
@@ -82,6 +86,7 @@ async function main() {
|
|
|
82
86
|
const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
|
|
83
87
|
const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
84
88
|
const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
|
|
89
|
+
const expoTailscale = flags.has('--expo-tailscale') || resolveExpoTailscaleEnabled({ env: process.env });
|
|
85
90
|
|
|
86
91
|
const serverDir = getComponentDir(rootDir, serverComponentName);
|
|
87
92
|
const uiDir = getComponentDir(rootDir, 'happy');
|
|
@@ -231,7 +236,7 @@ async function main() {
|
|
|
231
236
|
);
|
|
232
237
|
|
|
233
238
|
// Reliability before daemon start:
|
|
234
|
-
// - Ensure schema exists (server-light:
|
|
239
|
+
// - Ensure schema exists (server-light: prisma migrate deploy; happy-server: migrate deploy if tables missing)
|
|
235
240
|
// - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
|
|
236
241
|
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
237
242
|
const accountProbe = await getAccountCountForServerComponent({
|
|
@@ -267,6 +272,7 @@ async function main() {
|
|
|
267
272
|
envPath,
|
|
268
273
|
children,
|
|
269
274
|
spawnOptions: { stdio: ['ignore', 'ignore', 'ignore'] },
|
|
275
|
+
expoTailscale,
|
|
270
276
|
});
|
|
271
277
|
}
|
|
272
278
|
await maybeRunInteractiveStackAuthSetup({
|
|
@@ -300,6 +306,7 @@ async function main() {
|
|
|
300
306
|
stackName,
|
|
301
307
|
envPath,
|
|
302
308
|
children,
|
|
309
|
+
expoTailscale,
|
|
303
310
|
});
|
|
304
311
|
},
|
|
305
312
|
});
|
|
@@ -404,6 +411,7 @@ async function main() {
|
|
|
404
411
|
stackName,
|
|
405
412
|
envPath,
|
|
406
413
|
children,
|
|
414
|
+
expoTailscale,
|
|
407
415
|
}));
|
|
408
416
|
if (startUi) {
|
|
409
417
|
const uiPort = expoRes?.port;
|
|
@@ -435,6 +443,11 @@ async function main() {
|
|
|
435
443
|
console.log(`[local] mobile: metro ${metroUrl}`);
|
|
436
444
|
}
|
|
437
445
|
|
|
446
|
+
// Show Tailscale URL if forwarder is running
|
|
447
|
+
if (expoRes?.tailscale?.ok && expoRes.tailscale.tailscaleIp && expoRes.port) {
|
|
448
|
+
console.log(`[local] expo tailscale: http://${expoRes.tailscale.tailscaleIp}:${expoRes.port}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
438
451
|
const shutdown = async () => {
|
|
439
452
|
if (shuttingDown) {
|
|
440
453
|
return;
|
package/scripts/doctor.mjs
CHANGED
|
@@ -202,10 +202,6 @@ async function main() {
|
|
|
202
202
|
|
|
203
203
|
// UI build dir check
|
|
204
204
|
if (serveUi) {
|
|
205
|
-
if (serverComponentName !== 'happy-server-light') {
|
|
206
|
-
report.checks.uiServing = { ok: false, reason: `requires happy-server-light (current: ${serverComponentName})` };
|
|
207
|
-
if (!json) console.log(`ℹ️ ui serving requires happy-server-light (current: ${serverComponentName})`);
|
|
208
|
-
}
|
|
209
205
|
if (await pathExists(uiBuildDir)) {
|
|
210
206
|
report.checks.uiBuildDir = { ok: true, path: uiBuildDir };
|
|
211
207
|
if (!json) console.log('✅ ui build dir present');
|
package/scripts/edison.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { mkdir, lstat, rename, symlink, writeFile, readdir, chmod } from 'node:fs/promises';
|
|
16
16
|
import os from 'node:os';
|
|
17
|
+
import { normalizeGitRoots } from './utils/edison/git_roots.mjs';
|
|
17
18
|
|
|
18
19
|
const COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
19
20
|
|
|
@@ -1343,7 +1344,7 @@ async function resolveFingerprintGitRoots({ rootDir, stackEnv, edisonArgs }) {
|
|
|
1343
1344
|
const d = resolveComponentDirFromStackEnv({ rootDir, stackEnv, component: c });
|
|
1344
1345
|
if (d) dirs.push(d);
|
|
1345
1346
|
}
|
|
1346
|
-
return dirs.length ? dirs : fallback;
|
|
1347
|
+
return normalizeGitRoots(dirs.length ? dirs : fallback);
|
|
1347
1348
|
}
|
|
1348
1349
|
|
|
1349
1350
|
async function cmdTrackCoherence({ rootDir, argv, json }) {
|
|
@@ -1852,4 +1853,3 @@ main().catch((err) => {
|
|
|
1852
1853
|
console.error('[edison] failed:', err);
|
|
1853
1854
|
process.exit(1);
|
|
1854
1855
|
});
|
|
1855
|
-
|
package/scripts/env.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
4
|
+
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
5
|
+
import { parseEnvToObject } from './utils/env/dotenv.mjs';
|
|
6
|
+
import { resolveStackEnvPath } from './utils/paths/paths.mjs';
|
|
7
|
+
import { readTextOrEmpty } from './utils/fs/ops.mjs';
|
|
8
|
+
|
|
9
|
+
function resolveTargetEnvPath() {
|
|
10
|
+
// If we're already running under a stack wrapper, respect it.
|
|
11
|
+
const explicit = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
|
|
12
|
+
if (explicit) return explicit;
|
|
13
|
+
|
|
14
|
+
// Self-host default: no stacks knowledge required; persist in the main stack env file.
|
|
15
|
+
return resolveStackEnvPath('main').envPath;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const { flags } = parseArgs(argv);
|
|
21
|
+
const json = wantsJson(argv, { flags });
|
|
22
|
+
|
|
23
|
+
const helpText = [
|
|
24
|
+
'[env] usage:',
|
|
25
|
+
' happys env set KEY=VALUE [KEY2=VALUE2...]',
|
|
26
|
+
' happys env unset KEY [KEY2...]',
|
|
27
|
+
' happys env get KEY',
|
|
28
|
+
' happys env list',
|
|
29
|
+
' happys env path',
|
|
30
|
+
'',
|
|
31
|
+
'defaults:',
|
|
32
|
+
' - If running under a stack wrapper (HAPPY_STACKS_ENV_FILE is set), edits that stack env file.',
|
|
33
|
+
' - Otherwise, edits the main stack env file (~/.happy/stacks/main/env).',
|
|
34
|
+
'',
|
|
35
|
+
'notes:',
|
|
36
|
+
' - Changes take effect on next stack/daemon start (restart to apply).',
|
|
37
|
+
].join('\n');
|
|
38
|
+
|
|
39
|
+
if (wantsHelp(argv, { flags })) {
|
|
40
|
+
printResult({
|
|
41
|
+
json,
|
|
42
|
+
data: {
|
|
43
|
+
usage:
|
|
44
|
+
'happys env set KEY=VALUE [KEY2=VALUE2...] | unset KEY [KEY2...] | get KEY | list | path [--json]',
|
|
45
|
+
},
|
|
46
|
+
text: helpText,
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
52
|
+
const subcmd = (positionals[0] ?? '').trim() || 'help';
|
|
53
|
+
const envPath = resolveTargetEnvPath();
|
|
54
|
+
|
|
55
|
+
if (subcmd === 'help') {
|
|
56
|
+
printResult({
|
|
57
|
+
json,
|
|
58
|
+
data: {
|
|
59
|
+
usage: 'happys env set|unset|get|list|path [--json]',
|
|
60
|
+
},
|
|
61
|
+
text: helpText,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (subcmd === 'path') {
|
|
67
|
+
printResult({
|
|
68
|
+
json,
|
|
69
|
+
data: { ok: true, envPath },
|
|
70
|
+
text: envPath,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const raw = await readTextOrEmpty(envPath);
|
|
76
|
+
const parsed = parseEnvToObject(raw);
|
|
77
|
+
|
|
78
|
+
if (subcmd === 'list') {
|
|
79
|
+
const keys = Object.keys(parsed ?? {}).sort((a, b) => a.localeCompare(b));
|
|
80
|
+
const text = [
|
|
81
|
+
`[env] path: ${envPath}`,
|
|
82
|
+
...keys.map((k) => `${k}=${parsed[k] ?? ''}`),
|
|
83
|
+
].join('\n');
|
|
84
|
+
printResult({ json, data: { ok: true, envPath, env: parsed }, text });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (subcmd === 'get') {
|
|
89
|
+
const key = (positionals[1] ?? '').trim();
|
|
90
|
+
if (!key) {
|
|
91
|
+
throw new Error('[env] usage: happys env get KEY');
|
|
92
|
+
}
|
|
93
|
+
const value = Object.prototype.hasOwnProperty.call(parsed, key) ? parsed[key] : null;
|
|
94
|
+
printResult({
|
|
95
|
+
json,
|
|
96
|
+
data: { ok: true, envPath, key, value },
|
|
97
|
+
text: value == null ? '' : String(value),
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (subcmd === 'set') {
|
|
103
|
+
const pairs = positionals.slice(1);
|
|
104
|
+
if (!pairs.length) {
|
|
105
|
+
throw new Error('[env] usage: happys env set KEY=VALUE [KEY2=VALUE2...]');
|
|
106
|
+
}
|
|
107
|
+
const updates = pairs.map((p) => {
|
|
108
|
+
const idx = p.indexOf('=');
|
|
109
|
+
if (idx <= 0) {
|
|
110
|
+
throw new Error(`[env] set: expected KEY=VALUE, got: ${p}`);
|
|
111
|
+
}
|
|
112
|
+
const key = p.slice(0, idx).trim();
|
|
113
|
+
const value = p.slice(idx + 1);
|
|
114
|
+
if (!key) {
|
|
115
|
+
throw new Error(`[env] set: invalid key in: ${p}`);
|
|
116
|
+
}
|
|
117
|
+
return { key, value };
|
|
118
|
+
});
|
|
119
|
+
await ensureEnvFileUpdated({ envPath, updates });
|
|
120
|
+
const updatedKeys = updates.map((u) => u.key);
|
|
121
|
+
printResult({
|
|
122
|
+
json,
|
|
123
|
+
data: { ok: true, envPath, updatedKeys },
|
|
124
|
+
text: `[env] ok: set ${updatedKeys.join(', ')}\n[env] path: ${envPath}`,
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (subcmd === 'unset' || subcmd === 'remove' || subcmd === 'rm') {
|
|
130
|
+
const keys = positionals.slice(1).map((k) => k.trim()).filter(Boolean);
|
|
131
|
+
if (!keys.length) {
|
|
132
|
+
throw new Error('[env] usage: happys env unset KEY [KEY2...]');
|
|
133
|
+
}
|
|
134
|
+
await ensureEnvFilePruned({ envPath, removeKeys: keys });
|
|
135
|
+
printResult({
|
|
136
|
+
json,
|
|
137
|
+
data: { ok: true, envPath, removedKeys: keys },
|
|
138
|
+
text: `[env] ok: unset ${keys.join(', ')}\n[env] path: ${envPath}`,
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new Error(`[env] unknown subcommand: ${subcmd}\n[env] usage: happys env set|unset|get|list|path`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
main().catch((err) => {
|
|
147
|
+
console.error('[env] failed:', err);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
function runNode(args, { cwd, env }) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const cleanEnv = {};
|
|
12
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
13
|
+
if (v == null) continue;
|
|
14
|
+
cleanEnv[k] = String(v);
|
|
15
|
+
}
|
|
16
|
+
const proc = spawn(process.execPath, args, { cwd, env: cleanEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let stderr = '';
|
|
19
|
+
proc.stdout.on('data', (d) => (stdout += String(d)));
|
|
20
|
+
proc.stderr.on('data', (d) => (stderr += String(d)));
|
|
21
|
+
proc.on('error', reject);
|
|
22
|
+
proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('happys env path defaults to main stack env file when no explicit env file is set', async () => {
|
|
27
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const rootDir = dirname(scriptsDir);
|
|
29
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
|
|
30
|
+
|
|
31
|
+
const storageDir = join(tmp, 'storage');
|
|
32
|
+
const homeDir = join(tmp, 'home');
|
|
33
|
+
await mkdir(storageDir, { recursive: true });
|
|
34
|
+
await mkdir(homeDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const baseEnv = {
|
|
37
|
+
...process.env,
|
|
38
|
+
// Prevent loading the user's real ~/.happy-stacks/.env via canonical discovery.
|
|
39
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
40
|
+
HAPPY_STACKS_STORAGE_DIR: storageDir,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const res = await runNode([join(rootDir, 'scripts', 'env.mjs'), 'path', '--json'], {
|
|
44
|
+
cwd: rootDir,
|
|
45
|
+
env: baseEnv,
|
|
46
|
+
});
|
|
47
|
+
assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
48
|
+
|
|
49
|
+
const out = JSON.parse(res.stdout || '{}');
|
|
50
|
+
assert.equal(out.ok, true);
|
|
51
|
+
assert.ok(
|
|
52
|
+
typeof out.envPath === 'string' && out.envPath.endsWith('/main/env'),
|
|
53
|
+
`expected main env path to end with /main/env, got: ${out.envPath}`
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('happys env edits the explicit stack env file when HAPPY_STACKS_ENV_FILE is set', async () => {
|
|
58
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
59
|
+
const rootDir = dirname(scriptsDir);
|
|
60
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
|
|
61
|
+
|
|
62
|
+
const storageDir = join(tmp, 'storage');
|
|
63
|
+
const homeDir = join(tmp, 'home');
|
|
64
|
+
const stackName = 'exp1';
|
|
65
|
+
const envPath = join(storageDir, stackName, 'env');
|
|
66
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
67
|
+
await mkdir(homeDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
const baseEnv = {
|
|
70
|
+
...process.env,
|
|
71
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
72
|
+
HAPPY_STACKS_STORAGE_DIR: storageDir,
|
|
73
|
+
HAPPY_STACKS_ENV_FILE: envPath,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const res = await runNode([join(rootDir, 'scripts', 'env.mjs'), 'set', 'FOO=bar'], { cwd: rootDir, env: baseEnv });
|
|
77
|
+
assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
78
|
+
|
|
79
|
+
const raw = await readFile(envPath, 'utf-8');
|
|
80
|
+
assert.ok(raw.includes('FOO=bar'), `expected FOO in explicit env file\n${raw}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('happys env (no subcommand) prints usage and exits 0', async () => {
|
|
84
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
85
|
+
const rootDir = dirname(scriptsDir);
|
|
86
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
|
|
87
|
+
|
|
88
|
+
const storageDir = join(tmp, 'storage');
|
|
89
|
+
const homeDir = join(tmp, 'home');
|
|
90
|
+
await mkdir(storageDir, { recursive: true });
|
|
91
|
+
await mkdir(homeDir, { recursive: true });
|
|
92
|
+
|
|
93
|
+
const baseEnv = {
|
|
94
|
+
...process.env,
|
|
95
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
96
|
+
HAPPY_STACKS_STORAGE_DIR: storageDir,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const res = await runNode([join(rootDir, 'scripts', 'env.mjs')], { cwd: rootDir, env: baseEnv });
|
|
100
|
+
assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
101
|
+
assert.ok(res.stdout.includes('[env] usage:'), `expected usage output\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('happys env list prints keys in text mode', async () => {
|
|
105
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
106
|
+
const rootDir = dirname(scriptsDir);
|
|
107
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
|
|
108
|
+
|
|
109
|
+
const storageDir = join(tmp, 'storage');
|
|
110
|
+
const homeDir = join(tmp, 'home');
|
|
111
|
+
const stackName = 'exp1';
|
|
112
|
+
const envPath = join(storageDir, stackName, 'env');
|
|
113
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
114
|
+
await mkdir(homeDir, { recursive: true });
|
|
115
|
+
await writeFile(envPath, 'FOO=bar\n', 'utf-8');
|
|
116
|
+
|
|
117
|
+
const baseEnv = {
|
|
118
|
+
...process.env,
|
|
119
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
120
|
+
HAPPY_STACKS_STORAGE_DIR: storageDir,
|
|
121
|
+
HAPPY_STACKS_ENV_FILE: envPath,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const res = await runNode([join(rootDir, 'scripts', 'env.mjs'), 'list'], { cwd: rootDir, env: baseEnv });
|
|
125
|
+
assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
126
|
+
assert.ok(res.stdout.includes('FOO=bar'), `expected list output to include FOO=bar\nstdout:\n${res.stdout}`);
|
|
127
|
+
});
|
|
128
|
+
|
package/scripts/init.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/env/config.mjs';
|
|
@@ -177,12 +177,15 @@ async function main() {
|
|
|
177
177
|
process.env.HAPPY_LOCAL_HOME_DIR = process.env.HAPPY_LOCAL_HOME_DIR ?? homeDir;
|
|
178
178
|
|
|
179
179
|
const workspaceDirRaw = parseArgValue(argv, 'workspace-dir');
|
|
180
|
-
const
|
|
180
|
+
const workspaceDirExpanded = expandHome(firstNonEmpty(
|
|
181
181
|
workspaceDirRaw,
|
|
182
182
|
process.env.HAPPY_STACKS_WORKSPACE_DIR,
|
|
183
183
|
process.env.HAPPY_LOCAL_WORKSPACE_DIR,
|
|
184
184
|
join(homeDir, 'workspace'),
|
|
185
185
|
));
|
|
186
|
+
// If the user passes a relative --workspace-dir, interpret it as relative to the home dir
|
|
187
|
+
// (not the current cwd). This keeps setup predictable, especially when invoked via `npx`.
|
|
188
|
+
const workspaceDir = workspaceDirExpanded.startsWith('/') ? workspaceDirExpanded : resolve(homeDir, workspaceDirExpanded);
|
|
186
189
|
process.env.HAPPY_STACKS_WORKSPACE_DIR = workspaceDir;
|
|
187
190
|
process.env.HAPPY_LOCAL_WORKSPACE_DIR = process.env.HAPPY_LOCAL_WORKSPACE_DIR ?? workspaceDir;
|
|
188
191
|
|