happy-stacks 0.0.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 +314 -0
- package/bin/happys.mjs +168 -0
- package/docs/menubar.md +186 -0
- package/docs/mobile-ios.md +134 -0
- package/docs/remote-access.md +43 -0
- package/docs/server-flavors.md +79 -0
- package/docs/stacks.md +218 -0
- package/docs/tauri.md +62 -0
- package/docs/worktrees-and-forks.md +395 -0
- package/extras/swiftbar/auth-login.sh +31 -0
- package/extras/swiftbar/happy-stacks.5s.sh +218 -0
- package/extras/swiftbar/icons/happy-green.png +0 -0
- package/extras/swiftbar/icons/happy-orange.png +0 -0
- package/extras/swiftbar/icons/happy-red.png +0 -0
- package/extras/swiftbar/icons/logo-white.png +0 -0
- package/extras/swiftbar/install.sh +191 -0
- package/extras/swiftbar/lib/git.sh +330 -0
- package/extras/swiftbar/lib/icons.sh +105 -0
- package/extras/swiftbar/lib/render.sh +774 -0
- package/extras/swiftbar/lib/system.sh +190 -0
- package/extras/swiftbar/lib/utils.sh +205 -0
- package/extras/swiftbar/pnpm-term.sh +125 -0
- package/extras/swiftbar/pnpm.sh +21 -0
- package/extras/swiftbar/set-interval.sh +62 -0
- package/extras/swiftbar/set-server-flavor.sh +57 -0
- package/extras/swiftbar/wt-pr.sh +95 -0
- package/package.json +58 -0
- package/scripts/auth.mjs +272 -0
- package/scripts/build.mjs +204 -0
- package/scripts/cli-link.mjs +58 -0
- package/scripts/completion.mjs +364 -0
- package/scripts/daemon.mjs +349 -0
- package/scripts/dev.mjs +181 -0
- package/scripts/doctor.mjs +342 -0
- package/scripts/happy.mjs +79 -0
- package/scripts/init.mjs +232 -0
- package/scripts/install.mjs +379 -0
- package/scripts/menubar.mjs +107 -0
- package/scripts/mobile.mjs +305 -0
- package/scripts/run.mjs +236 -0
- package/scripts/self.mjs +298 -0
- package/scripts/server_flavor.mjs +125 -0
- package/scripts/service.mjs +526 -0
- package/scripts/stack.mjs +815 -0
- package/scripts/tailscale.mjs +278 -0
- package/scripts/uninstall.mjs +190 -0
- package/scripts/utils/args.mjs +17 -0
- package/scripts/utils/cli.mjs +24 -0
- package/scripts/utils/cli_registry.mjs +262 -0
- package/scripts/utils/config.mjs +40 -0
- package/scripts/utils/dotenv.mjs +30 -0
- package/scripts/utils/env.mjs +138 -0
- package/scripts/utils/env_file.mjs +59 -0
- package/scripts/utils/env_local.mjs +25 -0
- package/scripts/utils/fs.mjs +11 -0
- package/scripts/utils/paths.mjs +184 -0
- package/scripts/utils/pm.mjs +294 -0
- package/scripts/utils/ports.mjs +66 -0
- package/scripts/utils/proc.mjs +66 -0
- package/scripts/utils/runtime.mjs +30 -0
- package/scripts/utils/server.mjs +41 -0
- package/scripts/utils/smoke_help.mjs +45 -0
- package/scripts/utils/validate.mjs +47 -0
- package/scripts/utils/wizard.mjs +69 -0
- package/scripts/utils/worktrees.mjs +78 -0
- package/scripts/where.mjs +105 -0
- package/scripts/worktrees.mjs +1721 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
export function getHappysRegistry() {
|
|
2
|
+
/**
|
|
3
|
+
* Command definition shape:
|
|
4
|
+
* - name: primary token users type (e.g. "wt")
|
|
5
|
+
* - aliases: alternative tokens (e.g. ["server-flavor"])
|
|
6
|
+
* - kind: "node" | "external"
|
|
7
|
+
* - scriptRelPath: for kind==="node"
|
|
8
|
+
* - external: { cmd, argsFromRest?: (rest)=>string[] } for kind==="external"
|
|
9
|
+
* - argsFromRest: transform passed to the script (default: identity)
|
|
10
|
+
* - helpArgs: argv passed to show help (default: ["--help"])
|
|
11
|
+
* - rootUsage: optional line(s) for root usage output
|
|
12
|
+
* - description: short one-liner for root commands list
|
|
13
|
+
* - hidden: omit from root help (legacy aliases still work)
|
|
14
|
+
*/
|
|
15
|
+
const commands = [
|
|
16
|
+
{
|
|
17
|
+
name: 'init',
|
|
18
|
+
kind: 'node',
|
|
19
|
+
scriptRelPath: 'scripts/init.mjs',
|
|
20
|
+
rootUsage:
|
|
21
|
+
'happys init [--home-dir=PATH] [--workspace-dir=PATH] [--runtime-dir=PATH] [--install-path] [--no-runtime] [--no-bootstrap] [--] [bootstrap args...]',
|
|
22
|
+
description: 'Initialize ~/.happy-stacks (runtime + shims)',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'uninstall',
|
|
26
|
+
kind: 'node',
|
|
27
|
+
scriptRelPath: 'scripts/uninstall.mjs',
|
|
28
|
+
rootUsage: 'happys uninstall [--remove-workspace] [--remove-stacks] [--yes] [--json]',
|
|
29
|
+
description: 'Remove ~/.happy-stacks and related files',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'where',
|
|
33
|
+
aliases: ['env'],
|
|
34
|
+
kind: 'node',
|
|
35
|
+
scriptRelPath: 'scripts/where.mjs',
|
|
36
|
+
rootUsage: 'happys where [--json] (alias: env)',
|
|
37
|
+
description: 'Show resolved paths and env sources',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'bootstrap',
|
|
41
|
+
kind: 'node',
|
|
42
|
+
scriptRelPath: 'scripts/install.mjs',
|
|
43
|
+
rootUsage: 'happys bootstrap [-- ...]',
|
|
44
|
+
description: 'Clone/install components and deps',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'start',
|
|
48
|
+
kind: 'node',
|
|
49
|
+
scriptRelPath: 'scripts/run.mjs',
|
|
50
|
+
rootUsage: 'happys start [-- ...]',
|
|
51
|
+
description: 'Start local stack (prod-like)',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'dev',
|
|
55
|
+
kind: 'node',
|
|
56
|
+
scriptRelPath: 'scripts/dev.mjs',
|
|
57
|
+
rootUsage: 'happys dev [-- ...]',
|
|
58
|
+
description: 'Start local stack (dev)',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'build',
|
|
62
|
+
kind: 'node',
|
|
63
|
+
scriptRelPath: 'scripts/build.mjs',
|
|
64
|
+
rootUsage: 'happys build [-- ...]',
|
|
65
|
+
description: 'Build UI bundle',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'mobile',
|
|
69
|
+
kind: 'node',
|
|
70
|
+
scriptRelPath: 'scripts/mobile.mjs',
|
|
71
|
+
rootUsage: 'happys mobile [-- ...]',
|
|
72
|
+
description: 'Mobile helper (iOS)',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'doctor',
|
|
76
|
+
kind: 'node',
|
|
77
|
+
scriptRelPath: 'scripts/doctor.mjs',
|
|
78
|
+
rootUsage: 'happys doctor [--fix] [--json]',
|
|
79
|
+
description: 'Diagnose/fix local setup',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'self',
|
|
83
|
+
kind: 'node',
|
|
84
|
+
scriptRelPath: 'scripts/self.mjs',
|
|
85
|
+
rootUsage: 'happys self status|update|check [--json]',
|
|
86
|
+
description: 'Runtime install + self-update',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'auth',
|
|
90
|
+
kind: 'node',
|
|
91
|
+
scriptRelPath: 'scripts/auth.mjs',
|
|
92
|
+
rootUsage: 'happys auth status|login [--json]',
|
|
93
|
+
description: 'CLI auth helper',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'happy',
|
|
97
|
+
kind: 'node',
|
|
98
|
+
scriptRelPath: 'scripts/happy.mjs',
|
|
99
|
+
rootUsage: 'happys happy <happy-cli args...>',
|
|
100
|
+
description: 'Run happy-cli against this stack',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'wt',
|
|
104
|
+
kind: 'node',
|
|
105
|
+
scriptRelPath: 'scripts/worktrees.mjs',
|
|
106
|
+
rootUsage: 'happys wt <args...>',
|
|
107
|
+
description: 'Worktrees across components',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'srv',
|
|
111
|
+
aliases: ['server-flavor'],
|
|
112
|
+
kind: 'node',
|
|
113
|
+
scriptRelPath: 'scripts/server_flavor.mjs',
|
|
114
|
+
rootUsage: 'happys srv <status|use ...>',
|
|
115
|
+
description: 'Select server flavor',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'stack',
|
|
119
|
+
kind: 'node',
|
|
120
|
+
scriptRelPath: 'scripts/stack.mjs',
|
|
121
|
+
rootUsage: 'happys stack <args...>',
|
|
122
|
+
description: 'Multiple isolated stacks',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'tailscale',
|
|
126
|
+
kind: 'node',
|
|
127
|
+
scriptRelPath: 'scripts/tailscale.mjs',
|
|
128
|
+
rootUsage: 'happys tailscale <status|enable|disable|url ...>',
|
|
129
|
+
description: 'Tailscale Serve (HTTPS secure context)',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'service',
|
|
133
|
+
kind: 'node',
|
|
134
|
+
scriptRelPath: 'scripts/service.mjs',
|
|
135
|
+
rootUsage: 'happys service <install|uninstall|status|start|stop|restart|enable|disable|logs|tail>',
|
|
136
|
+
description: 'LaunchAgent service management',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'menubar',
|
|
140
|
+
kind: 'node',
|
|
141
|
+
scriptRelPath: 'scripts/menubar.mjs',
|
|
142
|
+
rootUsage: 'happys menubar <install|uninstall|open>',
|
|
143
|
+
description: 'SwiftBar menu bar plugin',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'completion',
|
|
147
|
+
kind: 'node',
|
|
148
|
+
scriptRelPath: 'scripts/completion.mjs',
|
|
149
|
+
rootUsage: 'happys completion <print|install> [--shell=zsh|bash|fish] [--json]',
|
|
150
|
+
description: 'Shell completions (optional)',
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// ---- Legacy aliases (hidden) ----
|
|
154
|
+
{ name: 'stack:doctor', kind: 'node', scriptRelPath: 'scripts/doctor.mjs', hidden: true },
|
|
155
|
+
{ name: 'stack:fix', kind: 'node', scriptRelPath: 'scripts/doctor.mjs', argsFromRest: (rest) => ['--fix', ...rest], hidden: true },
|
|
156
|
+
|
|
157
|
+
{ name: 'cli:link', kind: 'node', scriptRelPath: 'scripts/cli-link.mjs', hidden: true },
|
|
158
|
+
{ name: 'logs', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['logs', ...rest], hidden: true },
|
|
159
|
+
{ name: 'logs:tail', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['tail', ...rest], hidden: true },
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
name: 'service:status',
|
|
163
|
+
kind: 'node',
|
|
164
|
+
scriptRelPath: 'scripts/service.mjs',
|
|
165
|
+
argsFromRest: (rest) => ['status', ...rest],
|
|
166
|
+
hidden: true,
|
|
167
|
+
},
|
|
168
|
+
{ name: 'service:start', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['start', ...rest], hidden: true },
|
|
169
|
+
{ name: 'service:stop', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['stop', ...rest], hidden: true },
|
|
170
|
+
{ name: 'service:restart', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['restart', ...rest], hidden: true },
|
|
171
|
+
{ name: 'service:enable', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['enable', ...rest], hidden: true },
|
|
172
|
+
{ name: 'service:disable', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['disable', ...rest], hidden: true },
|
|
173
|
+
{ name: 'service:install', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['install', ...rest], hidden: true },
|
|
174
|
+
{ name: 'service:uninstall', kind: 'node', scriptRelPath: 'scripts/service.mjs', argsFromRest: (rest) => ['uninstall', ...rest], hidden: true },
|
|
175
|
+
|
|
176
|
+
{ name: 'tailscale:status', kind: 'node', scriptRelPath: 'scripts/tailscale.mjs', argsFromRest: (rest) => ['status', ...rest], hidden: true },
|
|
177
|
+
{ name: 'tailscale:enable', kind: 'node', scriptRelPath: 'scripts/tailscale.mjs', argsFromRest: (rest) => ['enable', ...rest], hidden: true },
|
|
178
|
+
{ name: 'tailscale:disable', kind: 'node', scriptRelPath: 'scripts/tailscale.mjs', argsFromRest: (rest) => ['disable', ...rest], hidden: true },
|
|
179
|
+
{ name: 'tailscale:reset', kind: 'node', scriptRelPath: 'scripts/tailscale.mjs', argsFromRest: (rest) => ['reset', ...rest], hidden: true },
|
|
180
|
+
{ name: 'tailscale:url', kind: 'node', scriptRelPath: 'scripts/tailscale.mjs', argsFromRest: (rest) => ['url', ...rest], hidden: true },
|
|
181
|
+
|
|
182
|
+
{ name: 'menubar:install', kind: 'node', scriptRelPath: 'scripts/menubar.mjs', argsFromRest: (rest) => ['menubar:install', ...rest], hidden: true },
|
|
183
|
+
{ name: 'menubar:uninstall', kind: 'node', scriptRelPath: 'scripts/menubar.mjs', argsFromRest: (rest) => ['menubar:uninstall', ...rest], hidden: true },
|
|
184
|
+
{ name: 'menubar:open', kind: 'node', scriptRelPath: 'scripts/menubar.mjs', argsFromRest: (rest) => ['menubar:open', ...rest], hidden: true },
|
|
185
|
+
|
|
186
|
+
{ name: 'mobile:prebuild', kind: 'node', scriptRelPath: 'scripts/mobile.mjs', argsFromRest: (rest) => ['--prebuild', '--clean', '--no-metro', ...rest], hidden: true },
|
|
187
|
+
{ name: 'mobile:ios', kind: 'node', scriptRelPath: 'scripts/mobile.mjs', argsFromRest: (rest) => ['--run-ios', '--no-metro', ...rest], hidden: true },
|
|
188
|
+
{
|
|
189
|
+
name: 'mobile:ios:release',
|
|
190
|
+
kind: 'node',
|
|
191
|
+
scriptRelPath: 'scripts/mobile.mjs',
|
|
192
|
+
argsFromRest: (rest) => ['--run-ios', '--no-metro', '--configuration=Release', ...rest],
|
|
193
|
+
hidden: true,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'mobile:install',
|
|
197
|
+
kind: 'node',
|
|
198
|
+
scriptRelPath: 'scripts/mobile.mjs',
|
|
199
|
+
argsFromRest: (rest) => ['--run-ios', '--no-metro', '--configuration=Release', ...rest],
|
|
200
|
+
hidden: true,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'mobile:devices',
|
|
204
|
+
kind: 'external',
|
|
205
|
+
external: { cmd: 'xcrun', argsFromRest: () => ['xcdevice', 'list'] },
|
|
206
|
+
hidden: true,
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
return { commands };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveHappysCommand(cmd) {
|
|
214
|
+
const registry = getHappysRegistry();
|
|
215
|
+
const map = new Map();
|
|
216
|
+
for (const c of registry.commands) {
|
|
217
|
+
map.set(c.name, c);
|
|
218
|
+
for (const a of c.aliases ?? []) {
|
|
219
|
+
map.set(a, c);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return map.get(cmd) ?? null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function commandHelpArgs(cmd) {
|
|
226
|
+
const c = resolveHappysCommand(cmd);
|
|
227
|
+
if (!c) return null;
|
|
228
|
+
return c.helpArgs ?? ['--help'];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function renderHappysRootHelp() {
|
|
232
|
+
const { commands } = getHappysRegistry();
|
|
233
|
+
const visible = commands.filter((c) => !c.hidden);
|
|
234
|
+
|
|
235
|
+
const usageLines = [];
|
|
236
|
+
for (const c of visible) {
|
|
237
|
+
if (!c.rootUsage) continue;
|
|
238
|
+
if (Array.isArray(c.rootUsage)) usageLines.push(...c.rootUsage);
|
|
239
|
+
else usageLines.push(c.rootUsage);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const rows = visible
|
|
243
|
+
.filter((c) => c.description)
|
|
244
|
+
.map((c) => ({ name: c.name, desc: c.description }))
|
|
245
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
246
|
+
|
|
247
|
+
const pad = rows.reduce((m, r) => Math.max(m, r.name.length), 0);
|
|
248
|
+
const commandsLines = rows.map((r) => ` ${r.name.padEnd(pad)} ${r.desc}`);
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
'happys - Happy Stacks CLI',
|
|
252
|
+
'',
|
|
253
|
+
'usage:',
|
|
254
|
+
...usageLines.map((l) => ` ${l}`),
|
|
255
|
+
'',
|
|
256
|
+
'commands:',
|
|
257
|
+
...commandsLines,
|
|
258
|
+
'',
|
|
259
|
+
'help:',
|
|
260
|
+
' happys help [command]',
|
|
261
|
+
].join('\n');
|
|
262
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { ensureEnvFileUpdated } from './env_file.mjs';
|
|
3
|
+
import { getHappyStacksHomeDir, resolveStackEnvPath } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
export function getHomeEnvPath() {
|
|
6
|
+
return join(getHappyStacksHomeDir(), '.env');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getHomeEnvLocalPath() {
|
|
10
|
+
return join(getHappyStacksHomeDir(), 'env.local');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveUserConfigEnvPath({ cliRootDir }) {
|
|
14
|
+
const explicit = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
|
|
15
|
+
if (explicit) {
|
|
16
|
+
return explicit;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// By default, persist configuration to the main stack env file so config is
|
|
20
|
+
// outside the repo and consistent across install modes.
|
|
21
|
+
//
|
|
22
|
+
// This also matches the stack env precedence in scripts/utils/env.mjs.
|
|
23
|
+
void cliRootDir;
|
|
24
|
+
return resolveStackEnvPath('main').envPath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function ensureHomeEnvUpdated({ updates }) {
|
|
28
|
+
await ensureEnvFileUpdated({ envPath: getHomeEnvPath(), updates });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function ensureHomeEnvLocalUpdated({ updates }) {
|
|
32
|
+
await ensureEnvFileUpdated({ envPath: getHomeEnvLocalPath(), updates });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
|
|
36
|
+
const envPath = resolveUserConfigEnvPath({ cliRootDir });
|
|
37
|
+
await ensureEnvFileUpdated({ envPath, updates });
|
|
38
|
+
return envPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function parseDotenv(contents) {
|
|
5
|
+
const out = new Map();
|
|
6
|
+
for (const rawLine of contents.split('\n')) {
|
|
7
|
+
const line = rawLine.trim();
|
|
8
|
+
if (!line || line.startsWith('#')) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const idx = line.indexOf('=');
|
|
12
|
+
if (idx <= 0) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const key = line.slice(0, idx).trim();
|
|
16
|
+
let value = line.slice(idx + 1).trim();
|
|
17
|
+
if (!key) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
21
|
+
value = value.slice(1, -1);
|
|
22
|
+
}
|
|
23
|
+
if (value.startsWith('~/')) {
|
|
24
|
+
value = join(homedir(), value.slice(2));
|
|
25
|
+
}
|
|
26
|
+
out.set(key, value);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { parseDotenv } from './dotenv.mjs';
|
|
7
|
+
|
|
8
|
+
async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
|
|
9
|
+
try {
|
|
10
|
+
const contents = await readFile(path, 'utf-8');
|
|
11
|
+
const parsed = parseDotenv(contents);
|
|
12
|
+
for (const [k, v] of parsed.entries()) {
|
|
13
|
+
const allowOverride = override && (!overridePrefix || k.startsWith(overridePrefix));
|
|
14
|
+
if (allowOverride || process.env[k] == null || process.env[k] === '') {
|
|
15
|
+
process.env[k] = v;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// ignore missing/invalid env file
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Load happy-stacks env (optional). This is intentionally lightweight and does not require extra deps.
|
|
24
|
+
// This file lives under scripts/utils/, so repo root is two directories up.
|
|
25
|
+
const __utilsDir = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const __scriptsDir = dirname(__utilsDir);
|
|
27
|
+
const __cliRootDir = dirname(__scriptsDir);
|
|
28
|
+
|
|
29
|
+
function expandHome(p) {
|
|
30
|
+
return p.replace(/^~(?=\/)/, homedir());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveHomeDir() {
|
|
34
|
+
const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
|
|
35
|
+
if (fromEnv) {
|
|
36
|
+
return expandHome(fromEnv);
|
|
37
|
+
}
|
|
38
|
+
return join(homedir(), '.happy-stacks');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function applyStacksPrefixMapping() {
|
|
42
|
+
// Canonicalize env var prefix:
|
|
43
|
+
// - prefer HAPPY_STACKS_* when set
|
|
44
|
+
// - continue supporting HAPPY_LOCAL_* (legacy) during migration
|
|
45
|
+
const keys = new Set(Object.keys(process.env));
|
|
46
|
+
const suffixes = new Set();
|
|
47
|
+
for (const k of keys) {
|
|
48
|
+
if (k.startsWith('HAPPY_STACKS_')) suffixes.add(k.slice('HAPPY_STACKS_'.length));
|
|
49
|
+
if (k.startsWith('HAPPY_LOCAL_')) suffixes.add(k.slice('HAPPY_LOCAL_'.length));
|
|
50
|
+
}
|
|
51
|
+
for (const suffix of suffixes) {
|
|
52
|
+
const stacksKey = `HAPPY_STACKS_${suffix}`;
|
|
53
|
+
const localKey = `HAPPY_LOCAL_${suffix}`;
|
|
54
|
+
const stacksVal = (process.env[stacksKey] ?? '').trim();
|
|
55
|
+
const localVal = (process.env[localKey] ?? '').trim();
|
|
56
|
+
if (stacksVal) {
|
|
57
|
+
// Stacks wins.
|
|
58
|
+
process.env[stacksKey] = stacksVal;
|
|
59
|
+
process.env[localKey] = stacksVal;
|
|
60
|
+
} else if (localVal) {
|
|
61
|
+
// Legacy -> stacks.
|
|
62
|
+
process.env[localKey] = localVal;
|
|
63
|
+
process.env[stacksKey] = localVal;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const __homeDir = resolveHomeDir();
|
|
69
|
+
process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? __homeDir;
|
|
70
|
+
|
|
71
|
+
// Prefer canonical home config:
|
|
72
|
+
// ~/.happy-stacks/.env
|
|
73
|
+
// ~/.happy-stacks/env.local
|
|
74
|
+
//
|
|
75
|
+
// Backwards compatible fallback for cloned-repo usage (when home config doesn't exist yet):
|
|
76
|
+
// <repo>/.env
|
|
77
|
+
// <repo>/env.local
|
|
78
|
+
const homeEnv = join(__homeDir, '.env');
|
|
79
|
+
const homeLocal = join(__homeDir, 'env.local');
|
|
80
|
+
const hasHomeConfig = existsSync(homeEnv) || existsSync(homeLocal);
|
|
81
|
+
|
|
82
|
+
// 1) Load defaults first (lowest precedence)
|
|
83
|
+
if (hasHomeConfig) {
|
|
84
|
+
await loadEnvFile(homeEnv, { override: false });
|
|
85
|
+
await loadEnvFile(homeLocal, { override: true, overridePrefix: 'HAPPY_LOCAL_' });
|
|
86
|
+
await loadEnvFile(homeLocal, { override: true, overridePrefix: 'HAPPY_STACKS_' });
|
|
87
|
+
} else {
|
|
88
|
+
await loadEnvFile(join(__cliRootDir, '.env'), { override: false });
|
|
89
|
+
await loadEnvFile(join(__cliRootDir, 'env.local'), { override: true, overridePrefix: 'HAPPY_LOCAL_' });
|
|
90
|
+
await loadEnvFile(join(__cliRootDir, 'env.local'), { override: true, overridePrefix: 'HAPPY_STACKS_' });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If no explicit env file is set, and we're on the default "main" stack, prefer the stack-scoped env file
|
|
94
|
+
// if it exists: ~/.happy/stacks/main/env
|
|
95
|
+
(() => {
|
|
96
|
+
const stacksEnv = (process.env.HAPPY_STACKS_ENV_FILE ?? '').trim();
|
|
97
|
+
const localEnv = (process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
|
|
98
|
+
if (stacksEnv || localEnv) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
102
|
+
if (stackName !== 'main') {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const mainEnv = join(homedir(), '.happy', 'stacks', 'main', 'env');
|
|
106
|
+
if (!existsSync(mainEnv)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
process.env.HAPPY_STACKS_ENV_FILE = mainEnv;
|
|
110
|
+
process.env.HAPPY_LOCAL_ENV_FILE = mainEnv;
|
|
111
|
+
})();
|
|
112
|
+
// 3) Load explicit env file overlay (stack env, or any caller-provided env file) last (highest precedence)
|
|
113
|
+
if (process.env.HAPPY_STACKS_ENV_FILE?.trim()) {
|
|
114
|
+
await loadEnvFile(process.env.HAPPY_STACKS_ENV_FILE.trim(), { override: true, overridePrefix: 'HAPPY_STACKS_' });
|
|
115
|
+
}
|
|
116
|
+
if (process.env.HAPPY_LOCAL_ENV_FILE?.trim()) {
|
|
117
|
+
await loadEnvFile(process.env.HAPPY_LOCAL_ENV_FILE.trim(), { override: true, overridePrefix: 'HAPPY_LOCAL_' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Make both prefixes available to the rest of the codebase.
|
|
121
|
+
applyStacksPrefixMapping();
|
|
122
|
+
|
|
123
|
+
// Corepack strictness can prevent running Yarn in subfolders when the repo root is pinned to pnpm.
|
|
124
|
+
// We intentionally keep component repos upstream-compatible (often Yarn), so relax strictness for child processes.
|
|
125
|
+
process.env.COREPACK_ENABLE_STRICT = process.env.COREPACK_ENABLE_STRICT ?? '0';
|
|
126
|
+
process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT ?? 'false';
|
|
127
|
+
|
|
128
|
+
// LaunchAgents often run with a very minimal PATH which won't include NVM's bin dir, so child
|
|
129
|
+
// processes like `yarn` / `pnpm` can look "missing" even though Node is running from NVM.
|
|
130
|
+
// Ensure the directory containing this Node binary is on PATH.
|
|
131
|
+
(() => {
|
|
132
|
+
const delimiter = process.platform === 'win32' ? ';' : ':';
|
|
133
|
+
const current = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
134
|
+
const nodeBinDir = dirname(process.execPath);
|
|
135
|
+
const want = [nodeBinDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
|
|
136
|
+
const next = [...want.filter((p) => p && !current.includes(p)), ...current];
|
|
137
|
+
process.env.PATH = next.join(delimiter);
|
|
138
|
+
})();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { pathExists } from './fs.mjs';
|
|
4
|
+
|
|
5
|
+
export async function ensureEnvFileUpdated({ envPath, updates }) {
|
|
6
|
+
if (!updates.length) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
10
|
+
await writeFileIfChanged(envPath, applyEnvUpdates(await readText(envPath), updates), envPath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function readText(path) {
|
|
14
|
+
try {
|
|
15
|
+
return (await pathExists(path)) ? await readFile(path, 'utf-8') : '';
|
|
16
|
+
} catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function applyEnvUpdates(existing, updates) {
|
|
22
|
+
const lines = existing.split('\n');
|
|
23
|
+
const next = [...lines];
|
|
24
|
+
|
|
25
|
+
const upsert = (key, value) => {
|
|
26
|
+
const line = `${key}=${value}`;
|
|
27
|
+
const idx = next.findIndex((l) => l.trim().startsWith(`${key}=`));
|
|
28
|
+
if (idx >= 0) {
|
|
29
|
+
next[idx] = line;
|
|
30
|
+
} else {
|
|
31
|
+
if (next.length && next[next.length - 1].trim() !== '') {
|
|
32
|
+
next.push('');
|
|
33
|
+
}
|
|
34
|
+
next.push(line);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (const { key, value } of updates) {
|
|
39
|
+
upsert(key, value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return next.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function writeFileIfChanged(existingContent, nextContent, path) {
|
|
46
|
+
const normalizedNext = nextContent.endsWith('\n') ? nextContent : nextContent + '\n';
|
|
47
|
+
const normalizedExisting = existingContent.endsWith('\n') ? existingContent : existingContent + (existingContent ? '\n' : '');
|
|
48
|
+
if (normalizedExisting === normalizedNext) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const dir = dirname(path);
|
|
53
|
+
// if dir doesn't exist, writeFile will throw; that's fine (we only target known files).
|
|
54
|
+
void dir;
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
await writeFile(path, normalizedNext, 'utf-8');
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ensureEnvFileUpdated } from './env_file.mjs';
|
|
5
|
+
import { ensureUserConfigEnvUpdated, getHomeEnvLocalPath, getHomeEnvPath } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
export async function ensureEnvLocalUpdated({ rootDir, updates }) {
|
|
8
|
+
// Behavior:
|
|
9
|
+
// - If a stack env file is explicitly set, write there (stack-scoped).
|
|
10
|
+
// - If the user has run `happys init` (home config exists), write to the main stack env file (user config).
|
|
11
|
+
// - If no home config exists (legacy cloned-repo usage), write to <repo>/env.local for repo-local behavior.
|
|
12
|
+
const explicit = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
|
|
13
|
+
if (explicit) {
|
|
14
|
+
await ensureEnvFileUpdated({ envPath: explicit, updates });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const hasHomeConfig = existsSync(getHomeEnvPath()) || existsSync(getHomeEnvLocalPath());
|
|
19
|
+
if (hasHomeConfig) {
|
|
20
|
+
await ensureUserConfigEnvUpdated({ cliRootDir: rootDir, updates });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await ensureEnvFileUpdated({ envPath: join(rootDir, 'env.local'), updates });
|
|
25
|
+
}
|