happy-stacks 0.6.12 → 0.6.13
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/docs/commit-audits/happy/_tools/generate-plans.mjs +453 -0
- package/docs/commit-audits/happy/_tools/generate-pr-assignment.mjs +430 -0
- package/docs/commit-audits/happy/_tools/init-pr-assignment-working.mjs +107 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +1849 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +747 -1
- package/docs/commit-audits/happy/leeroy-wip.commit-index.json +11740 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-index.tsv +252 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +18 -11
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1236 -92
- package/docs/commit-audits/happy/leeroy-wip.maintainers-overview.draft.md +448 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-assignment.draft.tsv +252 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-assignment.working.tsv +288 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-catalog.draft.md +245 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-stack-plan.draft.md +350 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-deferred-fragments.tsv +65 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-ledger.tsv +56 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-process.md +240 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-status.tsv +39 -0
- package/docs/commit-audits/happy/leeroy-wip.split-plan.draft.md +93 -0
- package/docs/commit-audits/happy/leeroy-wip.topic-buckets.md +76 -0
- package/docs/commit-audits/happy/pr-desc.extraction-ledger.tsv +279 -0
- package/docs/commit-audits/happy/pr-desc.original.md +0 -0
- package/docs/commit-audits/happy/pr-desc.post-audit-extraction-ledger.tsv +54 -0
- package/docs/commit-audits/happy/pr-desc.working-document.md +536 -0
- package/docs/happy-development.md +18 -1
- package/docs/isolated-linux-vm.md +23 -1
- package/docs/stacks.md +21 -1
- package/package.json +1 -1
- package/scripts/auth.mjs +46 -8
- package/scripts/daemon.mjs +44 -21
- package/scripts/doctor.mjs +2 -2
- package/scripts/doctor_cmd.test.mjs +67 -0
- package/scripts/happy.mjs +18 -5
- package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
- package/scripts/provision/macos-lima-happy-vm.sh +34 -2
- package/scripts/review.mjs +347 -124
- package/scripts/review_pr.mjs +78 -2
- package/scripts/run.mjs +2 -1
- package/scripts/stack.mjs +265 -19
- package/scripts/stack_daemon_cmd.test.mjs +196 -0
- package/scripts/stack_happy_cmd.test.mjs +103 -0
- package/scripts/utils/cli/prereqs.mjs +12 -1
- package/scripts/utils/dev/daemon.mjs +3 -1
- package/scripts/utils/proc/pm.mjs +1 -1
- package/scripts/utils/review/detached_worktree.mjs +61 -0
- package/scripts/utils/review/detached_worktree.test.mjs +62 -0
- package/scripts/utils/review/findings.mjs +133 -20
- package/scripts/utils/review/findings.test.mjs +88 -1
- package/scripts/utils/review/runners/augment.mjs +71 -0
- package/scripts/utils/review/runners/augment.test.mjs +42 -0
- package/scripts/utils/review/runners/coderabbit.mjs +54 -10
- package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
- package/scripts/utils/review/sliced_runner.mjs +39 -0
- package/scripts/utils/review/sliced_runner.test.mjs +47 -0
- package/scripts/utils/review/tool_home_seed.mjs +99 -0
- package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
- package/scripts/utils/stack/cli_identities.mjs +29 -0
- package/scripts/utils/stack/startup.mjs +45 -7
- package/scripts/worktrees.mjs +8 -5
package/scripts/review_pr.mjs
CHANGED
|
@@ -22,6 +22,13 @@ function usage() {
|
|
|
22
22
|
'[review-pr] usage:',
|
|
23
23
|
' happys review-pr --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--name=<stack>] [--dev|--start] [--mobile|--no-mobile] [--forks|--upstream] [--seed-auth|--no-seed-auth] [--copy-auth-from=<stack>] [--link-auth|--copy-auth] [--update] [--force] [--keep-sandbox] [--json] [-- <stack dev/start args...>]',
|
|
24
24
|
'',
|
|
25
|
+
'VM port forwarding (optional):',
|
|
26
|
+
'- `--vm-ports`: convenience preset for port-forwarded VMs (stack ports ~13xxx, Expo ports ~18xxx)',
|
|
27
|
+
'- `--stack-port-start=<n>`: sets HAPPY_STACKS_STACK_PORT_START inside the sandbox',
|
|
28
|
+
'- `--expo-dev-port-strategy=stable|ephemeral`: sets HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY inside the sandbox',
|
|
29
|
+
'- `--expo-dev-port-base=<n>` / `--expo-dev-port-range=<n>`: stable Expo port hashing params',
|
|
30
|
+
'- `--expo-dev-port=<n>`: force the Expo dev (Metro) port inside the sandbox',
|
|
31
|
+
'',
|
|
25
32
|
'What it does:',
|
|
26
33
|
'- creates a temporary sandbox dir',
|
|
27
34
|
'- runs `happys setup-pr ...` inside that sandbox (fully isolated state)',
|
|
@@ -70,6 +77,64 @@ function kvValue(argv, names) {
|
|
|
70
77
|
return null;
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
function stripArgv(argv, names) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const a of argv) {
|
|
83
|
+
let keep = true;
|
|
84
|
+
for (const n of names) {
|
|
85
|
+
if (a === n || a.startsWith(`${n}=`)) {
|
|
86
|
+
keep = false;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (keep) out.push(a);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveSandboxPortEnvOverrides(argv) {
|
|
96
|
+
const overrides = {};
|
|
97
|
+
|
|
98
|
+
// Convenience preset for VM review flows (pairs with Lima port-forward ranges in docs).
|
|
99
|
+
if (argvHasFlag(argv, ['--vm-ports'])) {
|
|
100
|
+
overrides.HAPPY_STACKS_STACK_PORT_START = '13005';
|
|
101
|
+
overrides.HAPPY_LOCAL_STACK_PORT_START = '13005';
|
|
102
|
+
|
|
103
|
+
// Keep Expo dev ports stable per stack so forwarded ports remain predictable.
|
|
104
|
+
overrides.HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY = 'stable';
|
|
105
|
+
overrides.HAPPY_STACKS_EXPO_DEV_PORT_BASE = '18081';
|
|
106
|
+
overrides.HAPPY_STACKS_EXPO_DEV_PORT_RANGE = '1000';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const stackPortStart = (kvValue(argv, ['--stack-port-start']) ?? '').trim();
|
|
110
|
+
if (stackPortStart) {
|
|
111
|
+
overrides.HAPPY_STACKS_STACK_PORT_START = stackPortStart;
|
|
112
|
+
overrides.HAPPY_LOCAL_STACK_PORT_START = stackPortStart;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const expoStrategy = (kvValue(argv, ['--expo-dev-port-strategy']) ?? '').trim().toLowerCase();
|
|
116
|
+
if (expoStrategy === 'stable' || expoStrategy === 'ephemeral') {
|
|
117
|
+
overrides.HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY = expoStrategy;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const expoBase = (kvValue(argv, ['--expo-dev-port-base']) ?? '').trim();
|
|
121
|
+
if (expoBase) {
|
|
122
|
+
overrides.HAPPY_STACKS_EXPO_DEV_PORT_BASE = expoBase;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const expoRange = (kvValue(argv, ['--expo-dev-port-range']) ?? '').trim();
|
|
126
|
+
if (expoRange) {
|
|
127
|
+
overrides.HAPPY_STACKS_EXPO_DEV_PORT_RANGE = expoRange;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const expoForced = (kvValue(argv, ['--expo-dev-port']) ?? '').trim();
|
|
131
|
+
if (expoForced) {
|
|
132
|
+
overrides.HAPPY_STACKS_EXPO_DEV_PORT = expoForced;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Object.keys(overrides).length ? overrides : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
73
138
|
async function main() {
|
|
74
139
|
const rootDir = getRootDir(import.meta.url);
|
|
75
140
|
const argv = process.argv.slice(2);
|
|
@@ -244,9 +309,20 @@ async function main() {
|
|
|
244
309
|
const hasNameFlag = argvWithDefaults.some((a) => a === '--name' || a.startsWith('--name='));
|
|
245
310
|
const argvFinal = hasNameFlag ? argvWithDefaults : [...argvWithDefaults, `--name=${effectiveStackName}`];
|
|
246
311
|
|
|
247
|
-
|
|
312
|
+
// Sandbox-only port overrides (useful for VM testing where host port-forwarding expects specific ranges).
|
|
313
|
+
const portEnv = resolveSandboxPortEnvOverrides(argvFinal);
|
|
314
|
+
const argvForSetupPr = stripArgv(argvFinal, [
|
|
315
|
+
'--vm-ports',
|
|
316
|
+
'--stack-port-start',
|
|
317
|
+
'--expo-dev-port-strategy',
|
|
318
|
+
'--expo-dev-port-base',
|
|
319
|
+
'--expo-dev-port-range',
|
|
320
|
+
'--expo-dev-port',
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'setup-pr', ...argvForSetupPr], {
|
|
248
324
|
cwd: rootDir,
|
|
249
|
-
env: process.env,
|
|
325
|
+
env: portEnv ? { ...process.env, ...portEnv } : process.env,
|
|
250
326
|
stdio: 'inherit',
|
|
251
327
|
});
|
|
252
328
|
|
package/scripts/run.mjs
CHANGED
|
@@ -271,11 +271,12 @@ async function main() {
|
|
|
271
271
|
: `file:${join(dataDir, 'happy-server-light.sqlite')}`;
|
|
272
272
|
|
|
273
273
|
// Reliability: ensure DB schema exists before daemon hits /v1/machines (health checks don't cover DB readiness).
|
|
274
|
+
// If the server is already running and we are not restarting, do NOT run migrations here (SQLite can lock).
|
|
274
275
|
const acct = await getAccountCountForServerComponent({
|
|
275
276
|
serverComponentName,
|
|
276
277
|
serverDir,
|
|
277
278
|
env: serverEnv,
|
|
278
|
-
bestEffort:
|
|
279
|
+
bestEffort: Boolean(serverAlreadyRunning && !restart),
|
|
279
280
|
});
|
|
280
281
|
serverLightAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
|
|
281
282
|
}
|
package/scripts/stack.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import './utils/env/env.mjs';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { chmod, copyFile, mkdir, open, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
// NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs';
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
} from './utils/stack/runtime_state.mjs';
|
|
65
65
|
import { killPid } from './utils/expo/expo.mjs';
|
|
66
66
|
import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
|
|
67
|
+
import { parseCliIdentityOrThrow, resolveCliHomeDirForIdentity } from './utils/stack/cli_identities.mjs';
|
|
67
68
|
import { randomToken } from './utils/crypto/tokens.mjs';
|
|
68
69
|
import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
|
|
69
70
|
import { sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
@@ -1432,10 +1433,12 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1432
1433
|
return;
|
|
1433
1434
|
}
|
|
1434
1435
|
|
|
1436
|
+
let exit = { code: null, sig: null, ok: false };
|
|
1435
1437
|
try {
|
|
1436
1438
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
1437
1439
|
child.on('error', rejectPromise);
|
|
1438
1440
|
child.on('exit', (code, sig) => {
|
|
1441
|
+
exit = { code: code ?? null, sig: sig ?? null, ok: code === 0 };
|
|
1439
1442
|
if (code === 0) return resolvePromise();
|
|
1440
1443
|
return rejectPromise(new Error(`stack ${scriptPath} exited (code=${code ?? 'null'}, sig=${sig ?? 'null'})`));
|
|
1441
1444
|
});
|
|
@@ -1443,7 +1446,26 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1443
1446
|
} finally {
|
|
1444
1447
|
const cur = await readStackRuntimeStateFile(runtimeStatePath);
|
|
1445
1448
|
if (Number(cur?.ownerPid) === Number(child.pid)) {
|
|
1446
|
-
|
|
1449
|
+
// Only delete runtime state when we're confident no child processes are left behind.
|
|
1450
|
+
// If the runner crashes but a child (server/expo/daemon) stays alive, keeping stack.runtime.json
|
|
1451
|
+
// allows `happys stack stop --aggressive` to kill the recorded PIDs safely.
|
|
1452
|
+
const processes = cur?.processes && typeof cur.processes === 'object' ? cur.processes : {};
|
|
1453
|
+
const anyAlive = Object.values(processes)
|
|
1454
|
+
.map((p) => Number(p))
|
|
1455
|
+
.some((pid) => Number.isFinite(pid) && pid > 1 && isPidAlive(pid));
|
|
1456
|
+
const portRaw = cur?.ports && typeof cur.ports === 'object' ? cur.ports.server : null;
|
|
1457
|
+
const port = Number(portRaw);
|
|
1458
|
+
const portOccupied =
|
|
1459
|
+
Number.isFinite(port) && port > 0 ? !(await isTcpPortFree(port, { host: '127.0.0.1' }).catch(() => true)) : false;
|
|
1460
|
+
|
|
1461
|
+
if (!anyAlive && !portOccupied) {
|
|
1462
|
+
await deleteStackRuntimeStateFile(runtimeStatePath);
|
|
1463
|
+
} else if (!wantsJson) {
|
|
1464
|
+
console.warn(
|
|
1465
|
+
`[stack] ${stackName}: preserving ${runtimeStatePath} after runner exit (child processes still alive). ` +
|
|
1466
|
+
`Run: happys stack stop ${stackName} --yes --aggressive`
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1447
1469
|
}
|
|
1448
1470
|
}
|
|
1449
1471
|
return;
|
|
@@ -3129,7 +3151,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
3129
3151
|
|
|
3130
3152
|
// Monorepo shortcut:
|
|
3131
3153
|
// If `--happy=<pr>` was provided and the local checkout is the slopus/happy monorepo, pin
|
|
3132
|
-
// happy-cli and
|
|
3154
|
+
// happy-cli and server flavors to that same worktree without fetching separate PRs.
|
|
3133
3155
|
if (happyMonorepoActive && prHappy) {
|
|
3134
3156
|
const happyWt = worktrees.find((w) => w?.component === 'happy');
|
|
3135
3157
|
const happyPath = String(happyWt?.path ?? '').trim();
|
|
@@ -3147,11 +3169,26 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
3147
3169
|
const derivedComponents = [
|
|
3148
3170
|
'happy-cli',
|
|
3149
3171
|
...(serverComponent === 'happy-server' ? ['happy-server'] : []),
|
|
3172
|
+
// If the user didn't explicitly provide a separate server-light PR, prefer the monorepo server/ dir.
|
|
3173
|
+
...(serverComponent === 'happy-server-light' && !prServerLight ? ['happy-server-light'] : []),
|
|
3150
3174
|
];
|
|
3151
3175
|
|
|
3152
3176
|
for (const c of derivedComponents) {
|
|
3153
3177
|
const p = derive(c);
|
|
3154
3178
|
if (!p) continue;
|
|
3179
|
+
if (c === 'happy-server-light') {
|
|
3180
|
+
const hasSqliteSchema =
|
|
3181
|
+
existsSync(join(p, 'prisma', 'sqlite', 'schema.prisma')) || existsSync(join(p, 'prisma', 'schema.sqlite.prisma'));
|
|
3182
|
+
if (!hasSqliteSchema) {
|
|
3183
|
+
throw new Error(
|
|
3184
|
+
'[stack] pr: happy-server-light was requested, but the monorepo server checkout does not include sqlite schema support.\n' +
|
|
3185
|
+
`- expected one of:\n` +
|
|
3186
|
+
` - ${join(p, 'prisma', 'sqlite', 'schema.prisma')}\n` +
|
|
3187
|
+
` - ${join(p, 'prisma', 'schema.sqlite.prisma')}\n` +
|
|
3188
|
+
'Fix: either switch to the full server flavor (--server=happy-server), or provide an explicit --happy-server-light=<pr>.'
|
|
3189
|
+
);
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3155
3192
|
if (!isComponentWorktreePath({ rootDir, component: c, dir: p, env: process.env })) {
|
|
3156
3193
|
throw new Error(`[stack] pr: refusing to pin ${c} because the derived path is not a worktree: ${p}`);
|
|
3157
3194
|
}
|
|
@@ -3376,27 +3413,30 @@ async function cmdStackOpen({ rootDir, stackName, json, includeStackDir, include
|
|
|
3376
3413
|
}
|
|
3377
3414
|
|
|
3378
3415
|
async function cmdStackDaemon({ rootDir, stackName, argv, json }) {
|
|
3379
|
-
const { flags } = parseArgs(argv);
|
|
3416
|
+
const { flags, kv } = parseArgs(argv);
|
|
3380
3417
|
const wantsHelpFlag = wantsHelp(argv, { flags });
|
|
3381
3418
|
|
|
3382
3419
|
const positionals = argv.filter((a) => a && a !== '--' && !a.startsWith('--'));
|
|
3383
3420
|
const action = (positionals[0] ?? 'status').toString().trim();
|
|
3421
|
+
const identity = parseCliIdentityOrThrow((kv.get('--identity') ?? '').trim());
|
|
3422
|
+
const noOpen = flags.has('--no-open') || flags.has('--no-browser') || flags.has('--no-browser-open');
|
|
3384
3423
|
|
|
3385
3424
|
if (wantsHelpFlag || !action || action === 'help') {
|
|
3386
3425
|
printResult({
|
|
3387
3426
|
json,
|
|
3388
|
-
data: { ok: true, stackName, commands: ['start', 'stop', 'restart', 'status'] },
|
|
3427
|
+
data: { ok: true, stackName, commands: ['start', 'stop', 'restart', 'status'], flags: ['--identity=<name>'] },
|
|
3389
3428
|
text: [
|
|
3390
3429
|
banner('stack daemon', { subtitle: `Manage the happy-cli daemon for stack ${cyan(stackName || 'main')}.` }),
|
|
3391
3430
|
'',
|
|
3392
3431
|
sectionTitle('usage:'),
|
|
3393
|
-
` ${cyan('happys stack daemon')} <name> status [--json]`,
|
|
3394
|
-
` ${cyan('happys stack daemon')} <name> start [--json]`,
|
|
3395
|
-
` ${cyan('happys stack daemon')} <name> stop [--json]`,
|
|
3396
|
-
` ${cyan('happys stack daemon')} <name> restart [--json]`,
|
|
3432
|
+
` ${cyan('happys stack daemon')} <name> status [--identity=<name>] [--json]`,
|
|
3433
|
+
` ${cyan('happys stack daemon')} <name> start [--identity=<name>] [--json]`,
|
|
3434
|
+
` ${cyan('happys stack daemon')} <name> stop [--identity=<name>] [--json]`,
|
|
3435
|
+
` ${cyan('happys stack daemon')} <name> restart [--identity=<name>] [--json]`,
|
|
3397
3436
|
'',
|
|
3398
3437
|
sectionTitle('example:'),
|
|
3399
3438
|
` ${cmdFmt(`happys stack daemon ${stackName || 'main'} restart`)}`,
|
|
3439
|
+
` ${cmdFmt(`happys stack daemon ${stackName || 'main'} start --identity=account-b`)}`,
|
|
3400
3440
|
].join('\n'),
|
|
3401
3441
|
});
|
|
3402
3442
|
return;
|
|
@@ -3423,37 +3463,106 @@ async function cmdStackDaemon({ rootDir, stackName, argv, json }) {
|
|
|
3423
3463
|
(env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() ||
|
|
3424
3464
|
getComponentDir(rootDir, 'happy-cli');
|
|
3425
3465
|
const cliBin = join(cliDir, 'bin', 'happy.mjs');
|
|
3426
|
-
const
|
|
3466
|
+
const baseCliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ??
|
|
3427
3467
|
env.HAPPY_LOCAL_CLI_HOME_DIR ??
|
|
3428
3468
|
join(resolveStackEnvPath(stackName).baseDir, 'cli')).toString();
|
|
3469
|
+
const cliHomeDir = resolveCliHomeDirForIdentity({ cliHomeDir: baseCliHomeDir, identity });
|
|
3429
3470
|
const serverPort = resolveServerPortFromEnv({ env, defaultPort: 3005 });
|
|
3430
3471
|
const urls = await resolveServerUrls({ env, serverPort, allowEnable: false });
|
|
3431
3472
|
const internalServerUrl = urls.internalServerUrl;
|
|
3432
3473
|
const publicServerUrl = urls.publicServerUrl;
|
|
3433
|
-
const
|
|
3474
|
+
const envForIdentity = {
|
|
3475
|
+
...env,
|
|
3476
|
+
HAPPY_STACKS_CLI_IDENTITY: identity,
|
|
3477
|
+
HAPPY_LOCAL_CLI_IDENTITY: identity,
|
|
3478
|
+
...(identity !== 'default'
|
|
3479
|
+
? {
|
|
3480
|
+
HAPPY_STACKS_MIGRATE_CREDENTIALS: '0',
|
|
3481
|
+
HAPPY_LOCAL_MIGRATE_CREDENTIALS: '0',
|
|
3482
|
+
HAPPY_STACKS_AUTO_AUTH_SEED: '0',
|
|
3483
|
+
HAPPY_LOCAL_AUTO_AUTH_SEED: '0',
|
|
3484
|
+
}
|
|
3485
|
+
: {}),
|
|
3486
|
+
};
|
|
3487
|
+
await mkdir(cliHomeDir, { recursive: true }).catch(() => {});
|
|
3488
|
+
const daemonEnv = getDaemonEnv({ baseEnv: envForIdentity, cliHomeDir, internalServerUrl, publicServerUrl });
|
|
3434
3489
|
|
|
3435
3490
|
if (action === 'start' || action === 'restart') {
|
|
3491
|
+
// UX: if this identity is not authenticated yet and we're in a real TTY, offer to run the
|
|
3492
|
+
// guided login flow inline (instead of failing or asking for a second terminal).
|
|
3493
|
+
//
|
|
3494
|
+
// Important: never prompt in --json mode (automation must not hang).
|
|
3495
|
+
const accessKeyPath = join(cliHomeDir, 'access.key');
|
|
3496
|
+
const hasCreds = (() => {
|
|
3497
|
+
try {
|
|
3498
|
+
if (!existsSync(accessKeyPath)) return false;
|
|
3499
|
+
return readFileSync(accessKeyPath, 'utf-8').trim().length > 0;
|
|
3500
|
+
} catch {
|
|
3501
|
+
return false;
|
|
3502
|
+
}
|
|
3503
|
+
})();
|
|
3504
|
+
|
|
3505
|
+
if (!hasCreds) {
|
|
3506
|
+
if (json) {
|
|
3507
|
+
const loginCmd = `happys stack auth ${stackName} login${identity !== 'default' ? ` --identity=${identity} --no-open` : ''}`;
|
|
3508
|
+
return { ok: false, action, error: 'auth_required', cliIdentity: identity, cliHomeDir, loginCmd };
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
if (isTty()) {
|
|
3512
|
+
const choice = await withRl(async (rl) => {
|
|
3513
|
+
return await promptSelect(rl, {
|
|
3514
|
+
title:
|
|
3515
|
+
`Daemon identity "${identity}" is not authenticated yet.\n` +
|
|
3516
|
+
`Authenticate now? (recommended)\n`,
|
|
3517
|
+
options: [
|
|
3518
|
+
{ label: 'yes (run guided login now)', value: 'yes' },
|
|
3519
|
+
{ label: 'no (show command and exit)', value: 'no' },
|
|
3520
|
+
],
|
|
3521
|
+
defaultIndex: 0,
|
|
3522
|
+
});
|
|
3523
|
+
});
|
|
3524
|
+
|
|
3525
|
+
if (choice === 'yes') {
|
|
3526
|
+
const authArgs = [
|
|
3527
|
+
'login',
|
|
3528
|
+
...(identity !== 'default' ? [`--identity=${identity}`] : []),
|
|
3529
|
+
...(identity !== 'default' || noOpen ? ['--no-open'] : []),
|
|
3530
|
+
];
|
|
3531
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...authArgs], {
|
|
3532
|
+
cwd: rootDir,
|
|
3533
|
+
env: envForIdentity,
|
|
3534
|
+
stdio: 'inherit',
|
|
3535
|
+
});
|
|
3536
|
+
} else {
|
|
3537
|
+
const loginCmd = `happys stack auth ${stackName} login${identity !== 'default' ? ` --identity=${identity} --no-open` : ''}`;
|
|
3538
|
+
throw new Error(`[stack] daemon auth required. Run:\n${loginCmd}`);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3436
3543
|
await startLocalDaemonWithAuth({
|
|
3437
3544
|
cliBin,
|
|
3438
3545
|
cliHomeDir,
|
|
3439
3546
|
internalServerUrl,
|
|
3440
3547
|
publicServerUrl,
|
|
3548
|
+
isShuttingDown: () => false,
|
|
3441
3549
|
forceRestart: action === 'restart',
|
|
3442
|
-
env,
|
|
3550
|
+
env: envForIdentity,
|
|
3443
3551
|
stackName,
|
|
3552
|
+
cliIdentity: identity,
|
|
3444
3553
|
});
|
|
3445
3554
|
const status = await runCapture(process.execPath, [cliBin, 'daemon', 'status'], { cwd: rootDir, env: daemonEnv });
|
|
3446
|
-
return { ok: true, action, status: status.trim() };
|
|
3555
|
+
return { ok: true, action, cliIdentity: identity, cliHomeDir, status: status.trim() };
|
|
3447
3556
|
}
|
|
3448
3557
|
|
|
3449
3558
|
if (action === 'stop') {
|
|
3450
3559
|
await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
|
|
3451
3560
|
const status = await runCapture(process.execPath, [cliBin, 'daemon', 'status'], { cwd: rootDir, env: daemonEnv }).catch(() => '');
|
|
3452
|
-
return { ok: true, action, status: status.trim() || null };
|
|
3561
|
+
return { ok: true, action, cliIdentity: identity, cliHomeDir, status: status.trim() || null };
|
|
3453
3562
|
}
|
|
3454
3563
|
|
|
3455
3564
|
const status = await runCapture(process.execPath, [cliBin, 'daemon', 'status'], { cwd: rootDir, env: daemonEnv });
|
|
3456
|
-
return { ok: true, action, status: status.trim() };
|
|
3565
|
+
return { ok: true, action, cliIdentity: identity, cliHomeDir, status: status.trim() };
|
|
3457
3566
|
},
|
|
3458
3567
|
});
|
|
3459
3568
|
|
|
@@ -3472,12 +3581,91 @@ async function cmdStackDaemon({ rootDir, stackName, argv, json }) {
|
|
|
3472
3581
|
console.log(`${green('✓')} daemon command completed`);
|
|
3473
3582
|
}
|
|
3474
3583
|
|
|
3584
|
+
const STACK_NAME_FIRST_SUPPORTED_COMMANDS = new Set([
|
|
3585
|
+
'help',
|
|
3586
|
+
'new',
|
|
3587
|
+
'edit',
|
|
3588
|
+
'list',
|
|
3589
|
+
'migrate',
|
|
3590
|
+
'audit',
|
|
3591
|
+
'archive',
|
|
3592
|
+
'duplicate',
|
|
3593
|
+
'info',
|
|
3594
|
+
'pr',
|
|
3595
|
+
'create-dev-auth-seed',
|
|
3596
|
+
'daemon',
|
|
3597
|
+
'happy',
|
|
3598
|
+
'env',
|
|
3599
|
+
'auth',
|
|
3600
|
+
'dev',
|
|
3601
|
+
'start',
|
|
3602
|
+
'build',
|
|
3603
|
+
'review',
|
|
3604
|
+
'typecheck',
|
|
3605
|
+
'lint',
|
|
3606
|
+
'test',
|
|
3607
|
+
'doctor',
|
|
3608
|
+
'mobile',
|
|
3609
|
+
'mobile:install',
|
|
3610
|
+
'mobile-dev-client',
|
|
3611
|
+
'resume',
|
|
3612
|
+
'stop',
|
|
3613
|
+
'code',
|
|
3614
|
+
'cursor',
|
|
3615
|
+
'open',
|
|
3616
|
+
'srv',
|
|
3617
|
+
'wt',
|
|
3618
|
+
'service',
|
|
3619
|
+
]);
|
|
3620
|
+
|
|
3621
|
+
function isKnownStackCommandToken(token) {
|
|
3622
|
+
const t = (token ?? '').toString().trim();
|
|
3623
|
+
if (!t) return false;
|
|
3624
|
+
if (t.startsWith('service:')) return true;
|
|
3625
|
+
if (t.startsWith('tailscale:')) return true;
|
|
3626
|
+
return STACK_NAME_FIRST_SUPPORTED_COMMANDS.has(t);
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
function normalizeStackNameFirstArgs(argv) {
|
|
3630
|
+
// Back-compat UX:
|
|
3631
|
+
// Allow `happys stack <name> <command> ...` (stack name first) as a shortcut for:
|
|
3632
|
+
// `happys stack <command> <name> ...`
|
|
3633
|
+
//
|
|
3634
|
+
// We only apply this rewrite when the first positional is *not* a known stack subcommand,
|
|
3635
|
+
// but *is* an existing stack name.
|
|
3636
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
3637
|
+
const positionalIdx = [];
|
|
3638
|
+
for (let i = 0; i < args.length; i++) {
|
|
3639
|
+
const a = args[i];
|
|
3640
|
+
if (!a) continue;
|
|
3641
|
+
if (a === '--') continue;
|
|
3642
|
+
if (a.startsWith('-')) continue;
|
|
3643
|
+
positionalIdx.push(i);
|
|
3644
|
+
if (positionalIdx.length >= 2) break;
|
|
3645
|
+
}
|
|
3646
|
+
if (positionalIdx.length < 2) return args;
|
|
3647
|
+
|
|
3648
|
+
const [i0, i1] = positionalIdx;
|
|
3649
|
+
const first = args[i0];
|
|
3650
|
+
const second = args[i1];
|
|
3651
|
+
|
|
3652
|
+
if (isKnownStackCommandToken(first)) return args;
|
|
3653
|
+
if (!isKnownStackCommandToken(second)) return args;
|
|
3654
|
+
if (!stackExistsSync(first)) return args;
|
|
3655
|
+
|
|
3656
|
+
const next = [...args];
|
|
3657
|
+
next[i0] = second;
|
|
3658
|
+
next[i1] = first;
|
|
3659
|
+
return next;
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3475
3662
|
async function main() {
|
|
3476
3663
|
const rootDir = getRootDir(import.meta.url);
|
|
3477
3664
|
// pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
|
|
3478
3665
|
// positional slicing behaves consistently.
|
|
3479
3666
|
const rawArgv = process.argv.slice(2);
|
|
3480
|
-
const
|
|
3667
|
+
const argv0 = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv;
|
|
3668
|
+
const argv = normalizeStackNameFirstArgs(argv0);
|
|
3481
3669
|
|
|
3482
3670
|
const { flags } = parseArgs(argv);
|
|
3483
3671
|
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
@@ -3744,11 +3932,65 @@ async function main() {
|
|
|
3744
3932
|
return;
|
|
3745
3933
|
}
|
|
3746
3934
|
if (cmd === 'happy') {
|
|
3747
|
-
|
|
3935
|
+
// Allow stack-scoped CLI identity selection:
|
|
3936
|
+
// - `happys stack happy <name> --identity=account-a -- <happy-cli args...>`
|
|
3937
|
+
// - (no passthrough args) `happys stack happy <name> --identity=account-a`
|
|
3938
|
+
//
|
|
3939
|
+
// Implementation detail: we set HAPPY_HOME_DIR (highest precedence) so anything that uses
|
|
3940
|
+
// the CLI home dir (credentials, daemon control, logs, etc.) uses the selected identity.
|
|
3941
|
+
const sepIdx = passthrough.indexOf('--');
|
|
3942
|
+
const wrapperArgs = sepIdx === -1 ? passthrough : passthrough.slice(0, sepIdx);
|
|
3943
|
+
const forwardedArgsRaw = sepIdx === -1 ? passthrough : passthrough.slice(sepIdx + 1);
|
|
3944
|
+
|
|
3945
|
+
// If there is no explicit `--`, treat `--identity=...` tokens as wrapper flags (since there are no
|
|
3946
|
+
// unambiguous happy-cli args to separate).
|
|
3947
|
+
const { kv } = parseArgs(wrapperArgs);
|
|
3948
|
+
const identityRaw = (kv.get('--identity') ?? '').toString().trim();
|
|
3949
|
+
const identity = identityRaw ? parseCliIdentityOrThrow(identityRaw) : null;
|
|
3950
|
+
|
|
3951
|
+
const forwardedArgs =
|
|
3952
|
+
sepIdx === -1
|
|
3953
|
+
? forwardedArgsRaw.filter((a) => !(identity && typeof a === 'string' && a.trim().startsWith('--identity=')))
|
|
3954
|
+
: forwardedArgsRaw;
|
|
3955
|
+
|
|
3748
3956
|
await withStackEnv({
|
|
3749
3957
|
stackName,
|
|
3750
3958
|
fn: async ({ env }) => {
|
|
3751
|
-
|
|
3959
|
+
// NOTE: resolve cli home using the *stack env* we just loaded, not the outer process env.
|
|
3960
|
+
// If identity is set, prefer our explicit HAPPY_HOME_DIR override.
|
|
3961
|
+
const baseCliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ??
|
|
3962
|
+
env.HAPPY_LOCAL_CLI_HOME_DIR ??
|
|
3963
|
+
join(resolveStackEnvPath(stackName).baseDir, 'cli')).toString();
|
|
3964
|
+
const cliHomeDirForIdentity = identity
|
|
3965
|
+
? resolveCliHomeDirForIdentity({ cliHomeDir: baseCliHomeDir, identity })
|
|
3966
|
+
: baseCliHomeDir;
|
|
3967
|
+
const envForHappy = identity
|
|
3968
|
+
? {
|
|
3969
|
+
...env,
|
|
3970
|
+
HAPPY_STACKS_CLI_IDENTITY: identity,
|
|
3971
|
+
HAPPY_LOCAL_CLI_IDENTITY: identity,
|
|
3972
|
+
// Highest-precedence signal for happy-cli: identity-scoped home dir.
|
|
3973
|
+
HAPPY_HOME_DIR: cliHomeDirForIdentity,
|
|
3974
|
+
// Keep stack helpers consistent too (some scripts use *_CLI_HOME_DIR).
|
|
3975
|
+
HAPPY_STACKS_CLI_HOME_DIR: cliHomeDirForIdentity,
|
|
3976
|
+
HAPPY_LOCAL_CLI_HOME_DIR: cliHomeDirForIdentity,
|
|
3977
|
+
}
|
|
3978
|
+
: env;
|
|
3979
|
+
|
|
3980
|
+
// Passthrough: preserve happy-cli output and exit code; avoid wrapper stack traces.
|
|
3981
|
+
const child = spawn(process.execPath, [join(rootDir, 'scripts', 'happy.mjs'), ...forwardedArgs], {
|
|
3982
|
+
cwd: rootDir,
|
|
3983
|
+
env: envForHappy,
|
|
3984
|
+
stdio: 'inherit',
|
|
3985
|
+
shell: false,
|
|
3986
|
+
});
|
|
3987
|
+
|
|
3988
|
+
const exitCode = await new Promise((resolvePromise) => {
|
|
3989
|
+
child.on('error', () => resolvePromise(1));
|
|
3990
|
+
child.on('exit', (code) => resolvePromise(code ?? 1));
|
|
3991
|
+
});
|
|
3992
|
+
|
|
3993
|
+
process.exit(exitCode);
|
|
3752
3994
|
},
|
|
3753
3995
|
});
|
|
3754
3996
|
return;
|
|
@@ -3977,6 +4219,10 @@ async function main() {
|
|
|
3977
4219
|
}
|
|
3978
4220
|
|
|
3979
4221
|
main().catch((err) => {
|
|
3980
|
-
|
|
4222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4223
|
+
console.error('[stack] failed:', message);
|
|
4224
|
+
if (process.env.DEBUG && err instanceof Error && err.stack) {
|
|
4225
|
+
console.error(err.stack);
|
|
4226
|
+
}
|
|
3981
4227
|
process.exit(1);
|
|
3982
4228
|
});
|