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,815 @@
|
|
|
1
|
+
import './utils/env.mjs';
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
|
|
6
|
+
import { parseArgs } from './utils/args.mjs';
|
|
7
|
+
import { run } from './utils/proc.mjs';
|
|
8
|
+
import { getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
|
|
9
|
+
import { createWorktree, resolveComponentSpecToDir } from './utils/worktrees.mjs';
|
|
10
|
+
import { isTty, prompt, promptWorktreeSource, withRl } from './utils/wizard.mjs';
|
|
11
|
+
import { parseDotenv } from './utils/dotenv.mjs';
|
|
12
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
13
|
+
|
|
14
|
+
function stackNameFromArg(positionals, idx) {
|
|
15
|
+
const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getStackDir(stackName) {
|
|
20
|
+
return resolveStackEnvPath(stackName).baseDir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getStackEnvPath(stackName) {
|
|
24
|
+
return resolveStackEnvPath(stackName).envPath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getDefaultPortStart() {
|
|
28
|
+
const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
|
|
29
|
+
? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
|
|
30
|
+
: process.env.HAPPY_LOCAL_STACK_PORT_START?.trim()
|
|
31
|
+
? process.env.HAPPY_LOCAL_STACK_PORT_START.trim()
|
|
32
|
+
: '';
|
|
33
|
+
const n = raw ? Number(raw) : 3005;
|
|
34
|
+
return Number.isFinite(n) ? n : 3005;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function isPortFree(port) {
|
|
38
|
+
return await new Promise((resolvePromise) => {
|
|
39
|
+
const srv = net.createServer();
|
|
40
|
+
srv.unref();
|
|
41
|
+
srv.on('error', () => resolvePromise(false));
|
|
42
|
+
srv.listen({ port, host: '127.0.0.1' }, () => {
|
|
43
|
+
srv.close(() => resolvePromise(true));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function pickNextFreePort(startPort) {
|
|
49
|
+
let port = startPort;
|
|
50
|
+
for (let i = 0; i < 200; i++) {
|
|
51
|
+
// eslint-disable-next-line no-await-in-loop
|
|
52
|
+
if (await isPortFree(port)) {
|
|
53
|
+
return port;
|
|
54
|
+
}
|
|
55
|
+
port += 1;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`[stack] unable to find a free port starting at ${startPort}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function ensureDir(p) {
|
|
61
|
+
await mkdir(p, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stringifyEnv(env) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
for (const [k, v] of Object.entries(env)) {
|
|
67
|
+
if (v == null) continue;
|
|
68
|
+
const s = String(v);
|
|
69
|
+
if (!s.trim()) continue;
|
|
70
|
+
// Keep it simple: no quoting/escaping beyond this.
|
|
71
|
+
lines.push(`${k}=${s}`);
|
|
72
|
+
}
|
|
73
|
+
return lines.join('\n') + '\n';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readExistingEnv(path) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(path, 'utf-8');
|
|
79
|
+
return raw;
|
|
80
|
+
} catch {
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseEnvToObject(raw) {
|
|
86
|
+
const parsed = parseDotenv(raw);
|
|
87
|
+
return Object.fromEntries(parsed.entries());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeStackEnv({ stackName, env }) {
|
|
91
|
+
const stackDir = getStackDir(stackName);
|
|
92
|
+
await ensureDir(stackDir);
|
|
93
|
+
const envPath = getStackEnvPath(stackName);
|
|
94
|
+
const next = stringifyEnv(env);
|
|
95
|
+
const existing = await readExistingEnv(envPath);
|
|
96
|
+
if (existing !== next) {
|
|
97
|
+
await writeFile(envPath, next, 'utf-8');
|
|
98
|
+
}
|
|
99
|
+
return envPath;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function withStackEnv({ stackName, fn, extraEnv = {} }) {
|
|
103
|
+
const envPath = getStackEnvPath(stackName);
|
|
104
|
+
// IMPORTANT: stack env file should be authoritative. If the user has HAPPY_STACKS_* / HAPPY_LOCAL_*
|
|
105
|
+
// exported in their shell, it would otherwise "win" because utils/env.mjs only sets
|
|
106
|
+
// env vars if they are missing/empty.
|
|
107
|
+
const cleaned = { ...process.env };
|
|
108
|
+
for (const k of Object.keys(cleaned)) {
|
|
109
|
+
if (k === 'HAPPY_LOCAL_ENV_FILE' || k === 'HAPPY_STACKS_ENV_FILE') continue;
|
|
110
|
+
if (k === 'HAPPY_LOCAL_STACK' || k === 'HAPPY_STACKS_STACK') continue;
|
|
111
|
+
if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
|
|
112
|
+
delete cleaned[k];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return await fn({
|
|
116
|
+
env: {
|
|
117
|
+
...cleaned,
|
|
118
|
+
HAPPY_STACKS_STACK: stackName,
|
|
119
|
+
HAPPY_STACKS_ENV_FILE: envPath,
|
|
120
|
+
HAPPY_LOCAL_STACK: stackName,
|
|
121
|
+
HAPPY_LOCAL_ENV_FILE: envPath,
|
|
122
|
+
...extraEnv,
|
|
123
|
+
},
|
|
124
|
+
envPath,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function interactiveNew({ rootDir, rl, defaults }) {
|
|
129
|
+
const out = { ...defaults };
|
|
130
|
+
|
|
131
|
+
if (!out.stackName) {
|
|
132
|
+
out.stackName = (await rl.question('Stack name: ')).trim();
|
|
133
|
+
}
|
|
134
|
+
if (!out.stackName) {
|
|
135
|
+
throw new Error('[stack] stack name is required');
|
|
136
|
+
}
|
|
137
|
+
if (out.stackName === 'main') {
|
|
138
|
+
throw new Error('[stack] stack name \"main\" is reserved (use the default stack without creating it)');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Server component selection
|
|
142
|
+
if (!out.serverComponent) {
|
|
143
|
+
const server = (await rl.question('Server component [happy-server-light|happy-server] (default: happy-server-light): ')).trim();
|
|
144
|
+
out.serverComponent = server || 'happy-server-light';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Port
|
|
148
|
+
if (!out.port) {
|
|
149
|
+
const want = (await rl.question('Port (empty = auto-pick): ')).trim();
|
|
150
|
+
out.port = want ? Number(want) : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Remote for creating new worktrees (used by all "create new worktree" choices)
|
|
154
|
+
if (!out.createRemote) {
|
|
155
|
+
out.createRemote = await prompt(rl, 'Git remote for creating new worktrees (default: upstream): ', { defaultValue: 'upstream' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Component selections
|
|
159
|
+
for (const c of ['happy', 'happy-cli']) {
|
|
160
|
+
if (out.components[c] != null) continue;
|
|
161
|
+
out.components[c] = await promptWorktreeSource({
|
|
162
|
+
rl,
|
|
163
|
+
rootDir,
|
|
164
|
+
component: c,
|
|
165
|
+
stackName: out.stackName,
|
|
166
|
+
createRemote: out.createRemote,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Server worktree selection (optional; only for the chosen server component)
|
|
171
|
+
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
172
|
+
if (out.components[serverComponent] == null) {
|
|
173
|
+
out.components[serverComponent] = await promptWorktreeSource({
|
|
174
|
+
rl,
|
|
175
|
+
rootDir,
|
|
176
|
+
component: serverComponent,
|
|
177
|
+
stackName: out.stackName,
|
|
178
|
+
createRemote: out.createRemote,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }) {
|
|
186
|
+
const out = { ...defaults, stackName };
|
|
187
|
+
|
|
188
|
+
// Server component selection
|
|
189
|
+
const currentServer = existingEnv.HAPPY_STACKS_SERVER_COMPONENT ?? existingEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '';
|
|
190
|
+
const server = await prompt(
|
|
191
|
+
rl,
|
|
192
|
+
`Server component [happy-server-light|happy-server] (default: ${currentServer || 'happy-server-light'}): `,
|
|
193
|
+
{ defaultValue: currentServer || 'happy-server-light' }
|
|
194
|
+
);
|
|
195
|
+
out.serverComponent = server || 'happy-server-light';
|
|
196
|
+
|
|
197
|
+
// Port
|
|
198
|
+
const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
|
|
199
|
+
const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'auto'}): `, { defaultValue: '' });
|
|
200
|
+
out.port = wantPort ? Number(wantPort) : (currentPort ? Number(currentPort) : null);
|
|
201
|
+
|
|
202
|
+
// Remote for creating new worktrees
|
|
203
|
+
const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
|
|
204
|
+
out.createRemote = await prompt(rl, `Git remote for creating new worktrees (default: ${currentRemote || 'upstream'}): `, {
|
|
205
|
+
defaultValue: currentRemote || 'upstream',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Worktree selections
|
|
209
|
+
for (const c of ['happy', 'happy-cli']) {
|
|
210
|
+
out.components[c] = await promptWorktreeSource({
|
|
211
|
+
rl,
|
|
212
|
+
rootDir,
|
|
213
|
+
component: c,
|
|
214
|
+
stackName,
|
|
215
|
+
createRemote: out.createRemote,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
220
|
+
out.components[serverComponent] = await promptWorktreeSource({
|
|
221
|
+
rl,
|
|
222
|
+
rootDir,
|
|
223
|
+
component: serverComponent,
|
|
224
|
+
stackName,
|
|
225
|
+
createRemote: out.createRemote,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function cmdNew({ rootDir, argv }) {
|
|
232
|
+
const { flags, kv } = parseArgs(argv);
|
|
233
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
234
|
+
const json = wantsJson(argv, { flags });
|
|
235
|
+
|
|
236
|
+
// argv here is already "args after 'new'", so the first positional is the stack name.
|
|
237
|
+
let stackName = stackNameFromArg(positionals, 0);
|
|
238
|
+
const interactive = flags.has('--interactive') || (!stackName && isTty());
|
|
239
|
+
|
|
240
|
+
const defaults = {
|
|
241
|
+
stackName,
|
|
242
|
+
port: kv.get('--port')?.trim() ? Number(kv.get('--port')) : null,
|
|
243
|
+
serverComponent: (kv.get('--server') ?? '').trim() || '',
|
|
244
|
+
createRemote: (kv.get('--remote') ?? '').trim() || '',
|
|
245
|
+
components: {
|
|
246
|
+
happy: kv.get('--happy')?.trim() || null,
|
|
247
|
+
'happy-cli': kv.get('--happy-cli')?.trim() || null,
|
|
248
|
+
'happy-server-light': kv.get('--happy-server-light')?.trim() || null,
|
|
249
|
+
'happy-server': kv.get('--happy-server')?.trim() || null,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
let config = defaults;
|
|
254
|
+
if (interactive) {
|
|
255
|
+
config = await withRl((rl) => interactiveNew({ rootDir, rl, defaults }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
stackName = config.stackName?.trim() ? config.stackName.trim() : '';
|
|
259
|
+
if (!stackName) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
'[stack] usage: happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] ' +
|
|
262
|
+
'[--happy=default|<owner/...>|<path>] [--happy-cli=...] [--happy-server=...] [--happy-server-light=...] [--interactive]'
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (stackName === 'main') {
|
|
266
|
+
throw new Error('[stack] stack name \"main\" is reserved (use the default stack without creating it)');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const serverComponent = (config.serverComponent || 'happy-server-light').trim();
|
|
270
|
+
if (serverComponent !== 'happy-server-light' && serverComponent !== 'happy-server') {
|
|
271
|
+
throw new Error(`[stack] invalid server component: ${serverComponent}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const baseDir = getStackDir(stackName);
|
|
275
|
+
const uiBuildDir = join(baseDir, 'ui');
|
|
276
|
+
const cliHomeDir = join(baseDir, 'cli');
|
|
277
|
+
|
|
278
|
+
let port = config.port;
|
|
279
|
+
if (!port || !Number.isFinite(port)) {
|
|
280
|
+
port = await pickNextFreePort(getDefaultPortStart());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Always pin component dirs explicitly (so stack env is stable even if repo env changes).
|
|
284
|
+
const defaultComponentDirs = {
|
|
285
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY: 'components/happy',
|
|
286
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI: 'components/happy-cli',
|
|
287
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT: 'components/happy-server-light',
|
|
288
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER: 'components/happy-server',
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Prepare component dirs (may create worktrees).
|
|
292
|
+
const stackEnv = {
|
|
293
|
+
HAPPY_STACKS_STACK: stackName,
|
|
294
|
+
HAPPY_STACKS_SERVER_PORT: String(port),
|
|
295
|
+
HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
|
|
296
|
+
HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
|
|
297
|
+
HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
|
|
298
|
+
HAPPY_STACKS_STACK_REMOTE: config.createRemote?.trim() ? config.createRemote.trim() : 'upstream',
|
|
299
|
+
...defaultComponentDirs,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// happy
|
|
303
|
+
const happySpec = config.components.happy;
|
|
304
|
+
if (happySpec && typeof happySpec === 'object' && happySpec.create) {
|
|
305
|
+
const dir = await createWorktree({ rootDir, component: 'happy', slug: happySpec.slug, remoteName: happySpec.remote || 'upstream' });
|
|
306
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = dir;
|
|
307
|
+
} else {
|
|
308
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy', spec: happySpec });
|
|
309
|
+
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = resolve(rootDir, dir);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// happy-cli
|
|
313
|
+
const cliSpec = config.components['happy-cli'];
|
|
314
|
+
if (cliSpec && typeof cliSpec === 'object' && cliSpec.create) {
|
|
315
|
+
const dir = await createWorktree({ rootDir, component: 'happy-cli', slug: cliSpec.slug, remoteName: cliSpec.remote || 'upstream' });
|
|
316
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = dir;
|
|
317
|
+
} else {
|
|
318
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-cli', spec: cliSpec });
|
|
319
|
+
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = resolve(rootDir, dir);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Server component directory override (optional)
|
|
323
|
+
if (serverComponent === 'happy-server-light') {
|
|
324
|
+
const spec = config.components['happy-server-light'];
|
|
325
|
+
if (spec && typeof spec === 'object' && spec.create) {
|
|
326
|
+
const dir = await createWorktree({
|
|
327
|
+
rootDir,
|
|
328
|
+
component: 'happy-server-light',
|
|
329
|
+
slug: spec.slug,
|
|
330
|
+
remoteName: spec.remote || 'upstream',
|
|
331
|
+
});
|
|
332
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = dir;
|
|
333
|
+
} else {
|
|
334
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-server-light', spec });
|
|
335
|
+
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = resolve(rootDir, dir);
|
|
336
|
+
}
|
|
337
|
+
} else if (serverComponent === 'happy-server') {
|
|
338
|
+
const spec = config.components['happy-server'];
|
|
339
|
+
if (spec && typeof spec === 'object' && spec.create) {
|
|
340
|
+
const dir = await createWorktree({ rootDir, component: 'happy-server', slug: spec.slug, remoteName: spec.remote || 'upstream' });
|
|
341
|
+
stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = dir;
|
|
342
|
+
} else {
|
|
343
|
+
const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-server', spec });
|
|
344
|
+
if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = resolve(rootDir, dir);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const envPath = await writeStackEnv({ stackName, env: stackEnv });
|
|
349
|
+
printResult({
|
|
350
|
+
json,
|
|
351
|
+
data: { stackName, envPath, port, serverComponent },
|
|
352
|
+
text: [`[stack] created ${stackName}`, `[stack] env: ${envPath}`, `[stack] port: ${port}`, `[stack] server: ${serverComponent}`].join('\n'),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function cmdEdit({ rootDir, argv }) {
|
|
357
|
+
const { flags } = parseArgs(argv);
|
|
358
|
+
const json = wantsJson(argv, { flags });
|
|
359
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
360
|
+
const stackName = stackNameFromArg(positionals, 1);
|
|
361
|
+
if (!stackName) {
|
|
362
|
+
throw new Error('[stack] usage: happys stack edit <name> [--interactive]');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const envPath = getStackEnvPath(stackName);
|
|
366
|
+
const raw = await readExistingEnv(envPath);
|
|
367
|
+
const existingEnv = parseEnvToObject(raw);
|
|
368
|
+
|
|
369
|
+
const interactive = flags.has('--interactive') || (!flags.has('--no-interactive') && isTty());
|
|
370
|
+
if (!interactive) {
|
|
371
|
+
throw new Error('[stack] edit currently requires --interactive (non-interactive editing not implemented yet).');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const defaults = {
|
|
375
|
+
stackName,
|
|
376
|
+
port: null,
|
|
377
|
+
serverComponent: '',
|
|
378
|
+
createRemote: '',
|
|
379
|
+
components: {
|
|
380
|
+
happy: null,
|
|
381
|
+
'happy-cli': null,
|
|
382
|
+
'happy-server-light': null,
|
|
383
|
+
'happy-server': null,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const config = await withRl((rl) => interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }));
|
|
388
|
+
|
|
389
|
+
// Build next env, starting from existing env but enforcing stack-scoped invariants.
|
|
390
|
+
const baseDir = getStackDir(stackName);
|
|
391
|
+
const uiBuildDir = join(baseDir, 'ui');
|
|
392
|
+
const cliHomeDir = join(baseDir, 'cli');
|
|
393
|
+
|
|
394
|
+
let port = config.port;
|
|
395
|
+
if (!port || !Number.isFinite(port)) {
|
|
396
|
+
port = await pickNextFreePort(getDefaultPortStart());
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const serverComponent = (config.serverComponent || existingEnv.HAPPY_STACKS_SERVER_COMPONENT || existingEnv.HAPPY_LOCAL_SERVER_COMPONENT || 'happy-server-light').trim();
|
|
400
|
+
|
|
401
|
+
const next = {
|
|
402
|
+
HAPPY_STACKS_STACK: stackName,
|
|
403
|
+
HAPPY_STACKS_SERVER_PORT: String(port),
|
|
404
|
+
HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
|
|
405
|
+
HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
|
|
406
|
+
HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
|
|
407
|
+
HAPPY_STACKS_STACK_REMOTE: config.createRemote?.trim()
|
|
408
|
+
? config.createRemote.trim()
|
|
409
|
+
: (existingEnv.HAPPY_STACKS_STACK_REMOTE || existingEnv.HAPPY_LOCAL_STACK_REMOTE || 'upstream'),
|
|
410
|
+
// Always pin defaults; overrides below can replace.
|
|
411
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY: 'components/happy',
|
|
412
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI: 'components/happy-cli',
|
|
413
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT: 'components/happy-server-light',
|
|
414
|
+
HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER: 'components/happy-server',
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Apply selections (create worktrees if needed)
|
|
418
|
+
const applyComponent = async (component, key, spec) => {
|
|
419
|
+
if (spec && typeof spec === 'object' && spec.create) {
|
|
420
|
+
next[key] = await createWorktree({ rootDir, component, slug: spec.slug, remoteName: spec.remote || next.HAPPY_STACKS_STACK_REMOTE });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const dir = resolveComponentSpecToDir({ rootDir, component, spec });
|
|
424
|
+
if (dir) {
|
|
425
|
+
next[key] = resolve(rootDir, dir);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
|
|
430
|
+
await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
|
|
431
|
+
if (serverComponent === 'happy-server') {
|
|
432
|
+
await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
|
|
433
|
+
} else {
|
|
434
|
+
await applyComponent('happy-server-light', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', config.components['happy-server-light']);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const wrote = await writeStackEnv({ stackName, env: next });
|
|
438
|
+
printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function cmdRunScript({ rootDir, stackName, scriptPath, args }) {
|
|
442
|
+
await withStackEnv({
|
|
443
|
+
stackName,
|
|
444
|
+
fn: async ({ env }) => {
|
|
445
|
+
await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function cmdService({ rootDir, stackName, svcCmd }) {
|
|
451
|
+
await withStackEnv({
|
|
452
|
+
stackName,
|
|
453
|
+
fn: async ({ env }) => {
|
|
454
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'service.mjs'), svcCmd], { cwd: rootDir, env });
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function cmdTailscale({ rootDir, stackName, subcmd, args }) {
|
|
460
|
+
await withStackEnv({
|
|
461
|
+
stackName,
|
|
462
|
+
fn: async ({ env }) => {
|
|
463
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), subcmd, ...args], { cwd: rootDir, env });
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function cmdSrv({ rootDir, stackName, args }) {
|
|
469
|
+
// Forward to scripts/server_flavor.mjs under the stack env.
|
|
470
|
+
const forwarded = args[0] === '--' ? args.slice(1) : args;
|
|
471
|
+
await withStackEnv({
|
|
472
|
+
stackName,
|
|
473
|
+
fn: async ({ env }) => {
|
|
474
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'server_flavor.mjs'), ...forwarded], { cwd: rootDir, env });
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function cmdWt({ rootDir, stackName, args }) {
|
|
480
|
+
// Forward to scripts/worktrees.mjs under the stack env.
|
|
481
|
+
// This makes `happys stack wt <name> -- ...` behave exactly like `happys wt ...`,
|
|
482
|
+
// but read/write the stack env file (HAPPY_STACKS_ENV_FILE / legacy: HAPPY_LOCAL_ENV_FILE) instead of repo env.local.
|
|
483
|
+
const forwarded = args[0] === '--' ? args.slice(1) : args;
|
|
484
|
+
await withStackEnv({
|
|
485
|
+
stackName,
|
|
486
|
+
fn: async ({ env }) => {
|
|
487
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...forwarded], { cwd: rootDir, env });
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function cmdAuth({ rootDir, stackName, args }) {
|
|
493
|
+
// Forward to scripts/auth.mjs under the stack env.
|
|
494
|
+
// This makes `happys stack auth <name> ...` resolve CLI home/urls for that stack.
|
|
495
|
+
const forwarded = args[0] === '--' ? args.slice(1) : args;
|
|
496
|
+
await withStackEnv({
|
|
497
|
+
stackName,
|
|
498
|
+
fn: async ({ env }) => {
|
|
499
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...forwarded], { cwd: rootDir, env });
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function cmdMigrate({ argv }) {
|
|
505
|
+
const { flags } = parseArgs(argv);
|
|
506
|
+
const json = wantsJson(argv, { flags });
|
|
507
|
+
|
|
508
|
+
const legacyDir = join(getLegacyStorageRoot(), 'stacks');
|
|
509
|
+
const newRoot = getStacksStorageRoot();
|
|
510
|
+
|
|
511
|
+
const migrated = [];
|
|
512
|
+
const skipped = [];
|
|
513
|
+
const missing = [];
|
|
514
|
+
|
|
515
|
+
let entries = [];
|
|
516
|
+
try {
|
|
517
|
+
entries = await readdir(legacyDir, { withFileTypes: true });
|
|
518
|
+
} catch {
|
|
519
|
+
entries = [];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!entries.length) {
|
|
523
|
+
printResult({
|
|
524
|
+
json,
|
|
525
|
+
data: { ok: true, migrated, skipped, missing, legacyDir, newRoot },
|
|
526
|
+
text: `[stack] no legacy stacks found at ${legacyDir}`,
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
for (const e of entries) {
|
|
532
|
+
if (!e.isDirectory()) continue;
|
|
533
|
+
const name = e.name;
|
|
534
|
+
const legacyEnv = join(legacyDir, name, 'env');
|
|
535
|
+
const targetEnv = join(newRoot, name, 'env');
|
|
536
|
+
|
|
537
|
+
const raw = await readExistingEnv(legacyEnv);
|
|
538
|
+
if (!raw.trim()) {
|
|
539
|
+
missing.push({ name, legacyEnv });
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const existingTarget = await readExistingEnv(targetEnv);
|
|
544
|
+
if (existingTarget.trim()) {
|
|
545
|
+
skipped.push({ name, targetEnv });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
await ensureDir(join(newRoot, name));
|
|
550
|
+
await writeFile(targetEnv, raw, 'utf-8');
|
|
551
|
+
migrated.push({ name, targetEnv });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
printResult({
|
|
555
|
+
json,
|
|
556
|
+
data: { ok: true, migrated, skipped, missing, legacyDir, newRoot },
|
|
557
|
+
text: [
|
|
558
|
+
`[stack] migrate complete`,
|
|
559
|
+
`[stack] legacy: ${legacyDir}`,
|
|
560
|
+
`[stack] new: ${newRoot}`,
|
|
561
|
+
migrated.length ? `[stack] migrated: ${migrated.length}` : `[stack] migrated: none`,
|
|
562
|
+
skipped.length ? `[stack] skipped (already exists): ${skipped.length}` : null,
|
|
563
|
+
missing.length ? `[stack] skipped (missing env): ${missing.length}` : null,
|
|
564
|
+
'',
|
|
565
|
+
`Next steps:`,
|
|
566
|
+
`- Re-run stacks normally (they'll prefer ${newRoot})`,
|
|
567
|
+
`- If you use autostart: re-install to get the new label/paths: happys service install`,
|
|
568
|
+
]
|
|
569
|
+
.filter(Boolean)
|
|
570
|
+
.join('\n'),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function cmdListStacks() {
|
|
575
|
+
const stacksDir = getStacksStorageRoot();
|
|
576
|
+
const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
|
|
577
|
+
try {
|
|
578
|
+
const namesSet = new Set();
|
|
579
|
+
const entries = await readdir(stacksDir, { withFileTypes: true });
|
|
580
|
+
for (const e of entries) {
|
|
581
|
+
if (!e.isDirectory()) continue;
|
|
582
|
+
if (e.name === 'main') continue;
|
|
583
|
+
namesSet.add(e.name);
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
|
|
587
|
+
for (const e of legacyEntries) {
|
|
588
|
+
if (!e.isDirectory()) continue;
|
|
589
|
+
namesSet.add(e.name);
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
// ignore
|
|
593
|
+
}
|
|
594
|
+
const names = Array.from(namesSet).sort();
|
|
595
|
+
if (!names.length) {
|
|
596
|
+
console.log('[stack] no stacks found');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
console.log('[stack] stacks:');
|
|
600
|
+
for (const n of names) {
|
|
601
|
+
console.log(`- ${n}`);
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
console.log('[stack] no stacks found');
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function main() {
|
|
609
|
+
const rootDir = getRootDir(import.meta.url);
|
|
610
|
+
// pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
|
|
611
|
+
// positional slicing behaves consistently.
|
|
612
|
+
const rawArgv = process.argv.slice(2);
|
|
613
|
+
const argv = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv;
|
|
614
|
+
|
|
615
|
+
const { flags } = parseArgs(argv);
|
|
616
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
617
|
+
const cmd = positionals[0] || 'help';
|
|
618
|
+
const json = wantsJson(argv, { flags });
|
|
619
|
+
|
|
620
|
+
if (wantsHelp(argv, { flags }) || cmd === 'help') {
|
|
621
|
+
printResult({
|
|
622
|
+
json,
|
|
623
|
+
data: { commands: ['new', 'edit', 'list', 'migrate', 'auth', 'dev', 'start', 'build', 'doctor', 'mobile', 'srv', 'wt', 'tailscale:*', 'service:*'] },
|
|
624
|
+
text: [
|
|
625
|
+
'[stack] usage:',
|
|
626
|
+
' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--happy-cli=...] [--interactive] [--json]',
|
|
627
|
+
' happys stack edit <name> --interactive [--json]',
|
|
628
|
+
' happys stack list [--json]',
|
|
629
|
+
' happys stack migrate [--json] # copy legacy env files from ~/.happy/local/stacks/* -> ~/.happy/stacks/*',
|
|
630
|
+
' happys stack auth <name> status|login [--json]',
|
|
631
|
+
' happys stack dev <name> [-- ...]',
|
|
632
|
+
' happys stack start <name> [-- ...]',
|
|
633
|
+
' happys stack build <name> [-- ...]',
|
|
634
|
+
' happys stack doctor <name> [-- ...]',
|
|
635
|
+
' happys stack mobile <name> [-- ...]',
|
|
636
|
+
' happys stack srv <name> -- status|use ...',
|
|
637
|
+
' happys stack wt <name> -- <wt args...>',
|
|
638
|
+
' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
|
|
639
|
+
' happys stack service <name> <install|uninstall|status|start|stop|restart|enable|disable|logs|tail>',
|
|
640
|
+
' happys stack service:* <name> # legacy alias',
|
|
641
|
+
].join('\n'),
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (cmd === 'new') {
|
|
647
|
+
await cmdNew({ rootDir, argv: argv.slice(1) });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (cmd === 'edit') {
|
|
651
|
+
await cmdEdit({ rootDir, argv });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (cmd === 'list') {
|
|
655
|
+
let names = [];
|
|
656
|
+
try {
|
|
657
|
+
const stacksDir = getStacksStorageRoot();
|
|
658
|
+
const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
|
|
659
|
+
const namesSet = new Set();
|
|
660
|
+
const entries = await readdir(stacksDir, { withFileTypes: true });
|
|
661
|
+
for (const e of entries) {
|
|
662
|
+
if (!e.isDirectory()) continue;
|
|
663
|
+
if (e.name === 'main') continue;
|
|
664
|
+
namesSet.add(e.name);
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
|
|
668
|
+
for (const e of legacyEntries) {
|
|
669
|
+
if (!e.isDirectory()) continue;
|
|
670
|
+
namesSet.add(e.name);
|
|
671
|
+
}
|
|
672
|
+
} catch {
|
|
673
|
+
// ignore
|
|
674
|
+
}
|
|
675
|
+
names = Array.from(namesSet).sort();
|
|
676
|
+
} catch {
|
|
677
|
+
names = [];
|
|
678
|
+
}
|
|
679
|
+
if (json) {
|
|
680
|
+
printResult({ json, data: { stacks: names } });
|
|
681
|
+
} else {
|
|
682
|
+
await cmdListStacks();
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (cmd === 'migrate') {
|
|
688
|
+
await cmdMigrate({ argv });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Commands that need a stack name.
|
|
693
|
+
const stackName = stackNameFromArg(positionals, 1);
|
|
694
|
+
if (!stackName) {
|
|
695
|
+
const helpLines =
|
|
696
|
+
cmd === 'service'
|
|
697
|
+
? [
|
|
698
|
+
'[stack] usage:',
|
|
699
|
+
' happys stack service <name> <install|uninstall|status|start|stop|restart|enable|disable|logs|tail>',
|
|
700
|
+
'',
|
|
701
|
+
'example:',
|
|
702
|
+
' happys stack service exp1 status',
|
|
703
|
+
]
|
|
704
|
+
: cmd === 'wt'
|
|
705
|
+
? [
|
|
706
|
+
'[stack] usage:',
|
|
707
|
+
' happys stack wt <name> -- <wt args...>',
|
|
708
|
+
'',
|
|
709
|
+
'example:',
|
|
710
|
+
' happys stack wt exp1 -- use happy slopus/pr/123-fix-thing',
|
|
711
|
+
]
|
|
712
|
+
: cmd === 'srv'
|
|
713
|
+
? [
|
|
714
|
+
'[stack] usage:',
|
|
715
|
+
' happys stack srv <name> -- status|use ...',
|
|
716
|
+
'',
|
|
717
|
+
'example:',
|
|
718
|
+
' happys stack srv exp1 -- status',
|
|
719
|
+
]
|
|
720
|
+
: cmd.startsWith('tailscale:')
|
|
721
|
+
? [
|
|
722
|
+
'[stack] usage:',
|
|
723
|
+
' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
|
|
724
|
+
'',
|
|
725
|
+
'example:',
|
|
726
|
+
' happys stack tailscale:status exp1',
|
|
727
|
+
]
|
|
728
|
+
: [
|
|
729
|
+
'[stack] missing stack name.',
|
|
730
|
+
'Run: happys stack --help',
|
|
731
|
+
];
|
|
732
|
+
|
|
733
|
+
printResult({ json, data: { ok: false, error: 'missing_stack_name', cmd }, text: helpLines.join('\n') });
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Remaining args after "<cmd> <name>"
|
|
738
|
+
const passthrough = argv.slice(2);
|
|
739
|
+
|
|
740
|
+
if (cmd === 'dev') {
|
|
741
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args: passthrough });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (cmd === 'start') {
|
|
745
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args: passthrough });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (cmd === 'build') {
|
|
749
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'build.mjs', args: passthrough });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (cmd === 'doctor') {
|
|
753
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (cmd === 'mobile') {
|
|
757
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (cmd === 'srv') {
|
|
762
|
+
await cmdSrv({ rootDir, stackName, args: passthrough });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (cmd === 'wt') {
|
|
766
|
+
await cmdWt({ rootDir, stackName, args: passthrough });
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (cmd === 'auth') {
|
|
770
|
+
await cmdAuth({ rootDir, stackName, args: passthrough });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (cmd === 'service') {
|
|
775
|
+
const svcCmd = passthrough[0];
|
|
776
|
+
if (!svcCmd) {
|
|
777
|
+
printResult({
|
|
778
|
+
json,
|
|
779
|
+
data: { ok: false, error: 'missing_service_subcommand', stackName },
|
|
780
|
+
text: [
|
|
781
|
+
'[stack] usage:',
|
|
782
|
+
' happys stack service <name> <install|uninstall|status|start|stop|restart|enable|disable|logs|tail>',
|
|
783
|
+
'',
|
|
784
|
+
'example:',
|
|
785
|
+
` happys stack service ${stackName} status`,
|
|
786
|
+
].join('\n'),
|
|
787
|
+
});
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
await cmdService({ rootDir, stackName, svcCmd });
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (cmd.startsWith('service:')) {
|
|
795
|
+
const svcCmd = cmd.slice('service:'.length);
|
|
796
|
+
await cmdService({ rootDir, stackName, svcCmd });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (cmd.startsWith('tailscale:')) {
|
|
800
|
+
const subcmd = cmd.slice('tailscale:'.length);
|
|
801
|
+
await cmdTailscale({ rootDir, stackName, subcmd, args: passthrough });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (flags.has('--interactive') && cmd === 'help') {
|
|
806
|
+
// no-op
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
console.log(`[stack] unknown command: ${cmd}`);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
main().catch((err) => {
|
|
813
|
+
console.error('[stack] failed:', err);
|
|
814
|
+
process.exit(1);
|
|
815
|
+
});
|