happy-stacks 0.1.2 → 0.3.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.
Files changed (116) hide show
  1. package/README.md +164 -89
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -0
  64. package/scripts/utils/dev/daemon.mjs +104 -0
  65. package/scripts/utils/dev/expo_web.mjs +112 -0
  66. package/scripts/utils/dev/server.mjs +183 -0
  67. package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
@@ -0,0 +1,163 @@
1
+ import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import { getHappyStacksHomeDir } from '../paths/paths.mjs';
6
+
7
+ export function getDevAuthKeyPath(env = process.env) {
8
+ return join(getHappyStacksHomeDir(env), 'keys', 'dev-auth.json');
9
+ }
10
+
11
+ function base64UrlToBytes(s) {
12
+ try {
13
+ const raw = String(s ?? '').trim();
14
+ if (!raw) return null;
15
+ const b64 = raw.replace(/-/g, '+').replace(/_/g, '/');
16
+ const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4));
17
+ return new Uint8Array(Buffer.from(b64 + pad, 'base64'));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function bytesToBase64Url(bytes) {
24
+ const b64 = Buffer.from(bytes).toString('base64');
25
+ return b64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
26
+ }
27
+
28
+ // Base32 alphabet (RFC 4648)
29
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
30
+
31
+ function bytesToBase32(bytes) {
32
+ let result = '';
33
+ let buffer = 0;
34
+ let bufferLength = 0;
35
+
36
+ for (const byte of bytes) {
37
+ buffer = (buffer << 8) | byte;
38
+ bufferLength += 8;
39
+ while (bufferLength >= 5) {
40
+ bufferLength -= 5;
41
+ result += BASE32_ALPHABET[(buffer >> bufferLength) & 0x1f];
42
+ }
43
+ }
44
+ if (bufferLength > 0) {
45
+ result += BASE32_ALPHABET[(buffer << (5 - bufferLength)) & 0x1f];
46
+ }
47
+ return result;
48
+ }
49
+
50
+ function base32ToBytes(base32) {
51
+ let normalized = String(base32 ?? '')
52
+ .toUpperCase()
53
+ .replace(/0/g, 'O')
54
+ .replace(/1/g, 'I')
55
+ .replace(/8/g, 'B')
56
+ .replace(/9/g, 'G');
57
+ const cleaned = normalized.replace(/[^A-Z2-7]/g, '');
58
+ if (!cleaned) throw new Error('no valid base32 characters');
59
+
60
+ const bytes = [];
61
+ let buffer = 0;
62
+ let bufferLength = 0;
63
+ for (const char of cleaned) {
64
+ const value = BASE32_ALPHABET.indexOf(char);
65
+ if (value === -1) throw new Error('invalid base32 character');
66
+ buffer = (buffer << 5) | value;
67
+ bufferLength += 5;
68
+ if (bufferLength >= 8) {
69
+ bufferLength -= 8;
70
+ bytes.push((buffer >> bufferLength) & 0xff);
71
+ }
72
+ }
73
+ return new Uint8Array(bytes);
74
+ }
75
+
76
+ export function normalizeDevAuthKeyInputToBytes(input) {
77
+ const raw = String(input ?? '').trim();
78
+ if (!raw) return null;
79
+
80
+ // Match Happy UI behavior:
81
+ // - backup format is base32 and is long (usually grouped with '-' / spaces)
82
+ // - base64url is short (~43 chars) and may contain '-' / '_' legitimately
83
+ //
84
+ // Key point: avoid mis-parsing backup base32 as base64.
85
+ if (raw.length > 50) {
86
+ try {
87
+ const b32 = base32ToBytes(raw);
88
+ return b32.length === 32 ? b32 : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ const b64 = base64UrlToBytes(raw);
95
+ if (b64 && b64.length === 32) return b64;
96
+ try {
97
+ const b32 = base32ToBytes(raw);
98
+ return b32.length === 32 ? b32 : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ export function formatDevAuthKeyBackup(secretKeyBase64Url) {
105
+ const bytes = base64UrlToBytes(secretKeyBase64Url);
106
+ if (!bytes || bytes.length !== 32) throw new Error('invalid secret key (expected base64url 32 bytes)');
107
+ const base32 = bytesToBase32(bytes);
108
+ const groups = [];
109
+ for (let i = 0; i < base32.length; i += 5) groups.push(base32.slice(i, i + 5));
110
+ return groups.join('-');
111
+ }
112
+
113
+ export async function readDevAuthKey({ env = process.env } = {}) {
114
+ if ((env.HAPPY_STACKS_DEV_AUTH_SECRET_KEY ?? '').toString().trim()) {
115
+ const bytes = normalizeDevAuthKeyInputToBytes(env.HAPPY_STACKS_DEV_AUTH_SECRET_KEY);
116
+ if (!bytes) return { ok: false, error: 'invalid_env_key', source: 'env', secretKeyBase64Url: null, backup: null };
117
+ const base64url = bytesToBase64Url(bytes);
118
+ return { ok: true, source: 'env:HAPPY_STACKS_DEV_AUTH_SECRET_KEY', secretKeyBase64Url: base64url, backup: formatDevAuthKeyBackup(base64url) };
119
+ }
120
+
121
+ const path = getDevAuthKeyPath(env);
122
+ try {
123
+ if (!existsSync(path)) return { ok: true, source: `file:${path}`, secretKeyBase64Url: null, backup: null, path };
124
+ const raw = await readFile(path, 'utf-8');
125
+ const parsed = JSON.parse(raw);
126
+ const input = parsed?.secretKeyBase64Url ?? parsed?.secretKey ?? parsed?.key ?? null;
127
+ const bytes = normalizeDevAuthKeyInputToBytes(input);
128
+ if (!bytes) return { ok: false, error: 'invalid_file_key', source: `file:${path}`, secretKeyBase64Url: null, backup: null, path };
129
+ const base64url = bytesToBase64Url(bytes);
130
+ return { ok: true, source: `file:${path}`, secretKeyBase64Url: base64url, backup: formatDevAuthKeyBackup(base64url), path };
131
+ } catch (e) {
132
+ return { ok: false, error: 'failed_to_read', source: `file:${path}`, secretKeyBase64Url: null, backup: null, path, details: e instanceof Error ? e.message : String(e) };
133
+ }
134
+ }
135
+
136
+ export async function writeDevAuthKey({ env = process.env, input } = {}) {
137
+ const bytes = normalizeDevAuthKeyInputToBytes(input);
138
+ if (!bytes || bytes.length !== 32) {
139
+ throw new Error('invalid secret key (expected 32 bytes; accept base64url or backup format)');
140
+ }
141
+ const secretKeyBase64Url = bytesToBase64Url(bytes);
142
+ const path = getDevAuthKeyPath(env);
143
+ await mkdir(dirname(path), { recursive: true });
144
+ const payload = {
145
+ v: 1,
146
+ createdAt: new Date().toISOString(),
147
+ secretKeyBase64Url,
148
+ };
149
+ await writeFile(path, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
150
+ await chmod(path, 0o600).catch(() => {});
151
+ return { ok: true, path, secretKeyBase64Url, backup: formatDevAuthKeyBackup(secretKeyBase64Url) };
152
+ }
153
+
154
+ export async function clearDevAuthKey({ env = process.env } = {}) {
155
+ const path = getDevAuthKeyPath(env);
156
+ try {
157
+ if (!existsSync(path)) return { ok: true, deleted: false, path };
158
+ await unlink(path);
159
+ return { ok: true, deleted: true, path };
160
+ } catch (e) {
161
+ return { ok: false, deleted: false, path, error: e instanceof Error ? e.message : String(e) };
162
+ }
163
+ }
@@ -0,0 +1,56 @@
1
+ import { chmod, copyFile, lstat, symlink, unlink, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+
5
+ import { ensureDir } from '../fs/ops.mjs';
6
+
7
+ export async function removeFileOrSymlinkIfExists(path) {
8
+ try {
9
+ const st = await lstat(path);
10
+ if (st.isDirectory()) {
11
+ throw new Error(`[auth] refusing to remove directory path: ${path}`);
12
+ }
13
+ await unlink(path);
14
+ return true;
15
+ } catch (e) {
16
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') return false;
17
+ throw e;
18
+ }
19
+ }
20
+
21
+ export async function writeSecretFileIfMissing({ path, secret, force = false }) {
22
+ if (!force && existsSync(path)) return false;
23
+ if (force && existsSync(path)) {
24
+ await removeFileOrSymlinkIfExists(path);
25
+ }
26
+ await ensureDir(dirname(path));
27
+ await writeFile(path, secret, { encoding: 'utf-8', mode: 0o600 });
28
+ return true;
29
+ }
30
+
31
+ export async function copyFileIfMissing({ from, to, mode, force = false }) {
32
+ if (!force && existsSync(to)) return false;
33
+ if (!existsSync(from)) return false;
34
+ await ensureDir(dirname(to));
35
+ // IMPORTANT: if `to` is a symlink and we "overwrite" it, do NOT write through it to the symlink target.
36
+ if (force && existsSync(to)) {
37
+ await removeFileOrSymlinkIfExists(to);
38
+ }
39
+ await copyFile(from, to);
40
+ if (mode) {
41
+ await chmod(to, mode).catch(() => {});
42
+ }
43
+ return true;
44
+ }
45
+
46
+ export async function linkFileIfMissing({ from, to, force = false }) {
47
+ if (!force && existsSync(to)) return false;
48
+ if (!existsSync(from)) return false;
49
+ await ensureDir(dirname(to));
50
+ if (force && existsSync(to)) {
51
+ await removeFileOrSymlinkIfExists(to);
52
+ }
53
+ await symlink(from, to);
54
+ return true;
55
+ }
56
+
@@ -0,0 +1,68 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ import { parseEnvToObject } from '../env/dotenv.mjs';
5
+ import { resolveStackEnvPath } from '../paths/paths.mjs';
6
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './sources.mjs';
7
+ import { getEnvValue } from '../env/values.mjs';
8
+ import { readTextIfExists } from '../fs/ops.mjs';
9
+ import { stackExistsSync } from '../stack/stacks.mjs';
10
+
11
+ export async function resolveHandyMasterSecretFromStack({
12
+ stackName,
13
+ requireStackExists = false,
14
+ allowLegacyAuthSource = true,
15
+ allowLegacyMainFallback = true,
16
+ } = {}) {
17
+ const name = String(stackName ?? '').trim() || 'main';
18
+
19
+ if (isLegacyAuthSourceName(name)) {
20
+ if (!allowLegacyAuthSource) {
21
+ throw new Error(
22
+ '[auth] legacy auth source is disabled in sandbox mode.\n' +
23
+ 'Reason: it reads from ~/.happy (global user state).\n' +
24
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
25
+ );
26
+ }
27
+ const baseDir = getLegacyHappyBaseDir();
28
+ const legacySecretPath = join(baseDir, 'server-light', 'handy-master-secret.txt');
29
+ const secret = await readTextIfExists(legacySecretPath);
30
+ return secret ? { secret, source: legacySecretPath } : { secret: null, source: null };
31
+ }
32
+
33
+ if (requireStackExists && !stackExistsSync(name)) {
34
+ throw new Error(`[auth] cannot copy auth: source stack "${name}" does not exist`);
35
+ }
36
+
37
+ const resolved = resolveStackEnvPath(name);
38
+ const sourceBaseDir = resolved.baseDir;
39
+ const sourceEnvPath = resolved.envPath;
40
+ const raw = await readTextIfExists(sourceEnvPath);
41
+ const env = raw ? parseEnvToObject(raw) : {};
42
+
43
+ const inline = getEnvValue(env, 'HANDY_MASTER_SECRET');
44
+ if (inline) {
45
+ return { secret: inline, source: `${sourceEnvPath} (HANDY_MASTER_SECRET)` };
46
+ }
47
+
48
+ const secretFile = getEnvValue(env, 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE');
49
+ if (secretFile) {
50
+ const secret = await readTextIfExists(secretFile);
51
+ if (secret) return { secret, source: secretFile };
52
+ }
53
+
54
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR') || join(sourceBaseDir, 'server-light');
55
+ const secretPath = join(dataDir, 'handy-master-secret.txt');
56
+ const secret = await readTextIfExists(secretPath);
57
+ if (secret) return { secret, source: secretPath };
58
+
59
+ // Last-resort legacy: if main has never been migrated to stack dirs.
60
+ if (name === 'main' && allowLegacyMainFallback) {
61
+ const legacy = join(homedir(), '.happy', 'server-light', 'handy-master-secret.txt');
62
+ const legacySecret = await readTextIfExists(legacy);
63
+ if (legacySecret) return { secret: legacySecret, source: legacy };
64
+ }
65
+
66
+ return { secret: null, source: null };
67
+ }
68
+
@@ -0,0 +1,76 @@
1
+ export function normalizeAuthLoginContext(raw) {
2
+ const v = String(raw ?? '')
3
+ .trim()
4
+ .toLowerCase();
5
+ if (v === 'selfhost' || v === 'self-host' || v === 'self_host') return 'selfhost';
6
+ if (v === 'dev' || v === 'developer' || v === 'development') return 'dev';
7
+ if (v === 'stack') return 'stack';
8
+ return 'generic';
9
+ }
10
+
11
+ export function printAuthLoginInstructions({
12
+ stackName,
13
+ context = 'generic',
14
+ webappUrl,
15
+ webappUrlSource,
16
+ internalServerUrl,
17
+ publicServerUrl,
18
+ rerunCmd,
19
+ }) {
20
+ const ctx = normalizeAuthLoginContext(context);
21
+ const title =
22
+ ctx === 'selfhost'
23
+ ? '[auth] login (self-host)'
24
+ : ctx === 'dev'
25
+ ? '[auth] login (dev)'
26
+ : ctx === 'stack'
27
+ ? `[auth] login (stack=${stackName || 'unknown'})`
28
+ : '[auth] login';
29
+
30
+ // eslint-disable-next-line no-console
31
+ console.log('');
32
+ // eslint-disable-next-line no-console
33
+ console.log(title);
34
+ // eslint-disable-next-line no-console
35
+ console.log('[auth] steps:');
36
+ // eslint-disable-next-line no-console
37
+ console.log(' 1) A browser window will open for authentication');
38
+ // eslint-disable-next-line no-console
39
+ console.log(' 2) Sign in (or create an account if this is your first time)');
40
+ // eslint-disable-next-line no-console
41
+ console.log(' 3) Approve this terminal/machine connection');
42
+ // eslint-disable-next-line no-console
43
+ console.log(' 4) Return here — the CLI will finish automatically');
44
+
45
+ if (webappUrl) {
46
+ // eslint-disable-next-line no-console
47
+ console.log('');
48
+ // eslint-disable-next-line no-console
49
+ console.log(`[auth] webapp: ${webappUrl}${webappUrlSource ? ` (${webappUrlSource})` : ''}`);
50
+ }
51
+ if (internalServerUrl) {
52
+ // eslint-disable-next-line no-console
53
+ console.log(`[auth] internal: ${internalServerUrl}`);
54
+ }
55
+ if (publicServerUrl) {
56
+ // eslint-disable-next-line no-console
57
+ console.log(`[auth] public: ${publicServerUrl}`);
58
+ }
59
+
60
+ if (ctx === 'selfhost') {
61
+ // eslint-disable-next-line no-console
62
+ console.log('');
63
+ // eslint-disable-next-line no-console
64
+ console.log('[auth] note: this is required so the daemon can register this machine and sync sessions across devices.');
65
+ }
66
+
67
+ // eslint-disable-next-line no-console
68
+ console.log('');
69
+ // eslint-disable-next-line no-console
70
+ console.log('[auth] tips:');
71
+ // eslint-disable-next-line no-console
72
+ console.log('- If the browser page does not load, make sure Happy is running and reachable.');
73
+ // eslint-disable-next-line no-console
74
+ console.log(`- Re-run anytime: ${rerunCmd || 'happys auth login'}`);
75
+ }
76
+
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ export function isLegacyAuthSourceName(name) {
5
+ const s = String(name ?? '').trim().toLowerCase();
6
+ return s === 'legacy' || s === 'system' || s === 'local-install';
7
+ }
8
+
9
+ export function getLegacyHappyBaseDir() {
10
+ return join(homedir(), '.happy');
11
+ }
12
+
@@ -20,6 +20,22 @@ export function getHappysRegistry() {
20
20
  rootUsage:
21
21
  'happys init [--home-dir=PATH] [--workspace-dir=PATH] [--runtime-dir=PATH] [--install-path] [--no-runtime] [--no-bootstrap] [--] [bootstrap args...]',
22
22
  description: 'Initialize ~/.happy-stacks (runtime + shims)',
23
+ hidden: true,
24
+ },
25
+ {
26
+ name: 'setup',
27
+ kind: 'node',
28
+ scriptRelPath: 'scripts/setup.mjs',
29
+ rootUsage: 'happys setup [--profile=selfhost|dev] [--json]',
30
+ description: 'Guided setup (selfhost or dev)',
31
+ },
32
+ {
33
+ name: 'setup-pr',
34
+ aliases: ['setupPR', 'setuppr'],
35
+ kind: 'node',
36
+ scriptRelPath: 'scripts/setup_pr.mjs',
37
+ rootUsage: 'happys setup-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev]',
38
+ description: 'One-shot: set up + run a PR stack (maintainer-friendly)',
23
39
  },
24
40
  {
25
41
  name: 'uninstall',
@@ -42,6 +58,7 @@ export function getHappysRegistry() {
42
58
  scriptRelPath: 'scripts/install.mjs',
43
59
  rootUsage: 'happys bootstrap [-- ...]',
44
60
  description: 'Clone/install components and deps',
61
+ hidden: true,
45
62
  },
46
63
  {
47
64
  name: 'start',
@@ -71,6 +88,13 @@ export function getHappysRegistry() {
71
88
  rootUsage: 'happys build [-- ...]',
72
89
  description: 'Build UI bundle',
73
90
  },
91
+ {
92
+ name: 'lint',
93
+ kind: 'node',
94
+ scriptRelPath: 'scripts/lint.mjs',
95
+ rootUsage: 'happys lint [component...] [--json]',
96
+ description: 'Run linters for components',
97
+ },
74
98
  {
75
99
  name: 'typecheck',
76
100
  aliases: ['type-check', 'check-types'],
@@ -79,6 +103,20 @@ export function getHappysRegistry() {
79
103
  rootUsage: 'happys typecheck [component...] [--json]',
80
104
  description: 'Run TypeScript typechecks for components',
81
105
  },
106
+ {
107
+ name: 'test',
108
+ kind: 'node',
109
+ scriptRelPath: 'scripts/test.mjs',
110
+ rootUsage: 'happys test [component...] [--json]',
111
+ description: 'Run tests for components',
112
+ },
113
+ {
114
+ name: 'edison',
115
+ kind: 'node',
116
+ scriptRelPath: 'scripts/edison.mjs',
117
+ rootUsage: 'happys edison [--stack=<name>] -- <edison args...>',
118
+ description: 'Run Edison with Happy Stacks integration',
119
+ },
82
120
  {
83
121
  name: 'migrate',
84
122
  kind: 'node',
@@ -100,6 +138,13 @@ export function getHappysRegistry() {
100
138
  rootUsage: 'happys doctor [--fix] [--json]',
101
139
  description: 'Diagnose/fix local setup',
102
140
  },
141
+ {
142
+ name: 'tui',
143
+ kind: 'node',
144
+ scriptRelPath: 'scripts/tui.mjs',
145
+ rootUsage: 'happys tui <happys args...> [--json]',
146
+ description: 'Run happys commands in a split-pane TUI',
147
+ },
103
148
  {
104
149
  name: 'self',
105
150
  kind: 'node',
@@ -272,6 +317,9 @@ export function renderHappysRootHelp() {
272
317
  return [
273
318
  'happys - Happy Stacks CLI',
274
319
  '',
320
+ 'global flags:',
321
+ ' --sandbox-dir PATH Run fully isolated under PATH (no writes to your real ~/.happy-stacks or ~/.happy/stacks)',
322
+ '',
275
323
  'usage:',
276
324
  ...usageLines.map((l) => ` ${l}`),
277
325
  '',
@@ -0,0 +1,17 @@
1
+ export function boolFromFlags({ flags, onFlag, offFlag, defaultValue }) {
2
+ if (flags.has(offFlag)) return false;
3
+ if (flags.has(onFlag)) return true;
4
+ return defaultValue;
5
+ }
6
+
7
+ export function boolFromFlagsOrKv({ flags, kv, onFlag, offFlag, key, defaultValue }) {
8
+ if (flags.has(offFlag)) return false;
9
+ if (flags.has(onFlag)) return true;
10
+ if (key && kv.has(key)) {
11
+ const raw = String(kv.get(key) ?? '').trim().toLowerCase();
12
+ if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y') return true;
13
+ if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'n') return false;
14
+ }
15
+ return defaultValue;
16
+ }
17
+
@@ -0,0 +1,16 @@
1
+ export function normalizeProfile(raw) {
2
+ const v = (raw ?? '').trim().toLowerCase();
3
+ if (!v) return '';
4
+ if (v === 'selfhost' || v === 'self-host' || v === 'self_host' || v === 'host') return 'selfhost';
5
+ if (v === 'dev' || v === 'developer' || v === 'develop' || v === 'development') return 'dev';
6
+ return '';
7
+ }
8
+
9
+ export function normalizeServerComponent(raw) {
10
+ const v = (raw ?? '').trim().toLowerCase();
11
+ if (!v) return '';
12
+ if (v === 'light' || v === 'server-light' || v === 'happy-server-light') return 'happy-server-light';
13
+ if (v === 'server' || v === 'full' || v === 'happy-server') return 'happy-server';
14
+ return '';
15
+ }
16
+
@@ -6,8 +6,8 @@ import { dirname } from 'node:path';
6
6
  import { getHappysRegistry } from './cli_registry.mjs';
7
7
 
8
8
  function cliRootDir() {
9
- // scripts/utils/* -> scripts -> repo root
10
- return dirname(dirname(dirname(fileURLToPath(import.meta.url))));
9
+ // scripts/utils/cli/* -> scripts/utils -> scripts -> repo root
10
+ return dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))));
11
11
  }
12
12
 
13
13
  function runOrThrow(label, args) {
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
- import { listWorktreeSpecs } from './worktrees.mjs';
2
+ import { listWorktreeSpecs } from '../git/worktrees.mjs';
3
3
 
4
4
  export function isTty() {
5
5
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -0,0 +1,14 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ export function base64Url(buf) {
4
+ return Buffer.from(buf)
5
+ .toString('base64')
6
+ .replaceAll('+', '-')
7
+ .replaceAll('/', '_')
8
+ .replaceAll('=', '');
9
+ }
10
+
11
+ export function randomToken(lenBytes = 24) {
12
+ return base64Url(randomBytes(lenBytes));
13
+ }
14
+
@@ -0,0 +1,104 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ import { ensureCliBuilt, ensureDepsInstalled } from '../proc/pm.mjs';
4
+ import { watchDebounced } from '../proc/watch.mjs';
5
+ import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from '../stack/startup.mjs';
6
+ import { startLocalDaemonWithAuth } from '../../daemon.mjs';
7
+
8
+ export async function ensureDevCliReady({ cliDir, buildCli }) {
9
+ await ensureDepsInstalled(cliDir, 'happy-cli');
10
+ return await ensureCliBuilt(cliDir, { buildCli });
11
+ }
12
+
13
+ export async function prepareDaemonAuthSeed({
14
+ rootDir,
15
+ env,
16
+ stackName,
17
+ cliHomeDir,
18
+ startDaemon,
19
+ isInteractive,
20
+ serverComponentName,
21
+ serverDir,
22
+ serverEnv,
23
+ quiet = false,
24
+ }) {
25
+ if (!startDaemon) return { ok: true, skipped: true, reason: 'no_daemon' };
26
+ const acct = await getAccountCountForServerComponent({
27
+ serverComponentName,
28
+ serverDir,
29
+ env: serverEnv,
30
+ bestEffort: serverComponentName === 'happy-server',
31
+ });
32
+ return await prepareDaemonAuthSeedIfNeeded({
33
+ rootDir,
34
+ env,
35
+ stackName,
36
+ cliHomeDir,
37
+ startDaemon,
38
+ isInteractive,
39
+ accountCount: typeof acct.accountCount === 'number' ? acct.accountCount : null,
40
+ quiet,
41
+ // IMPORTANT: run auth seeding under the same env used for server probes (includes DATABASE_URL).
42
+ authEnv: serverEnv,
43
+ });
44
+ }
45
+
46
+ export async function startDevDaemon({
47
+ startDaemon,
48
+ cliBin,
49
+ cliHomeDir,
50
+ internalServerUrl,
51
+ publicServerUrl,
52
+ restart,
53
+ isShuttingDown,
54
+ }) {
55
+ if (!startDaemon) return;
56
+ await startLocalDaemonWithAuth({
57
+ cliBin,
58
+ cliHomeDir,
59
+ internalServerUrl,
60
+ publicServerUrl,
61
+ isShuttingDown,
62
+ forceRestart: Boolean(restart),
63
+ });
64
+ }
65
+
66
+ export function watchHappyCliAndRestartDaemon({
67
+ enabled,
68
+ startDaemon,
69
+ buildCli,
70
+ cliDir,
71
+ cliBin,
72
+ cliHomeDir,
73
+ internalServerUrl,
74
+ publicServerUrl,
75
+ isShuttingDown,
76
+ }) {
77
+ if (!enabled || !startDaemon) return null;
78
+
79
+ let inFlight = false;
80
+ return watchDebounced({
81
+ paths: [resolve(cliDir)],
82
+ debounceMs: 500,
83
+ onChange: async () => {
84
+ if (isShuttingDown?.()) return;
85
+ if (inFlight) return;
86
+ inFlight = true;
87
+ try {
88
+ // eslint-disable-next-line no-console
89
+ console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
90
+ await ensureCliBuilt(cliDir, { buildCli });
91
+ await startLocalDaemonWithAuth({
92
+ cliBin,
93
+ cliHomeDir,
94
+ internalServerUrl,
95
+ publicServerUrl,
96
+ isShuttingDown,
97
+ forceRestart: true,
98
+ });
99
+ } finally {
100
+ inFlight = false;
101
+ }
102
+ },
103
+ });
104
+ }