happy-stacks 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -40
- package/bin/happys.mjs +158 -16
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
- package/scripts/utils/dev/expo_web.mjs +0 -112
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { inferComponentFromCwd } from './cwd_scope.mjs';
|
|
8
|
+
|
|
9
|
+
async function withTempRoot(t) {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-cwd-scope-'));
|
|
11
|
+
t.after(async () => {
|
|
12
|
+
await rm(dir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('inferComponentFromCwd resolves components/<component> repo root', async (t) => {
|
|
18
|
+
const rootDir = await withTempRoot(t);
|
|
19
|
+
const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
20
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
|
|
21
|
+
t.after(() => {
|
|
22
|
+
if (prevWorkspace == null) {
|
|
23
|
+
delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
24
|
+
} else {
|
|
25
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const repoRoot = join(rootDir, 'components', 'happy');
|
|
30
|
+
await mkdir(join(repoRoot, 'src'), { recursive: true });
|
|
31
|
+
await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
|
|
32
|
+
|
|
33
|
+
const invokedCwd = join(repoRoot, 'src');
|
|
34
|
+
const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy', 'happy-cli'] });
|
|
35
|
+
assert.deepEqual(inferred, { component: 'happy', repoDir: repoRoot });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('inferComponentFromCwd resolves happy monorepo subpackages under components/happy', async (t) => {
|
|
39
|
+
const rootDir = await withTempRoot(t);
|
|
40
|
+
const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
41
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
|
|
42
|
+
t.after(() => {
|
|
43
|
+
if (prevWorkspace == null) {
|
|
44
|
+
delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
45
|
+
} else {
|
|
46
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const monoRoot = join(rootDir, 'components', 'happy');
|
|
51
|
+
await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
|
|
52
|
+
await mkdir(join(monoRoot, 'cli', 'src'), { recursive: true });
|
|
53
|
+
await mkdir(join(monoRoot, 'server'), { recursive: true });
|
|
54
|
+
await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
55
|
+
await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
56
|
+
await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
57
|
+
await writeFile(join(monoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
|
|
58
|
+
|
|
59
|
+
const invokedCwd = join(monoRoot, 'cli', 'src');
|
|
60
|
+
const inferred = inferComponentFromCwd({
|
|
61
|
+
rootDir,
|
|
62
|
+
invokedCwd,
|
|
63
|
+
components: ['happy', 'happy-cli', 'happy-server'],
|
|
64
|
+
});
|
|
65
|
+
assert.deepEqual(inferred, { component: 'happy-cli', repoDir: monoRoot });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('inferComponentFromCwd resolves happy monorepo worktree roots under components/.worktrees/happy', async (t) => {
|
|
69
|
+
const rootDir = await withTempRoot(t);
|
|
70
|
+
const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
71
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
|
|
72
|
+
t.after(() => {
|
|
73
|
+
if (prevWorkspace == null) {
|
|
74
|
+
delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
75
|
+
} else {
|
|
76
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const repoRoot = join(rootDir, 'components', '.worktrees', 'happy', 'slopus', 'pr', '123-fix');
|
|
81
|
+
await mkdir(join(repoRoot, 'expo-app'), { recursive: true });
|
|
82
|
+
await mkdir(join(repoRoot, 'cli', 'nested'), { recursive: true });
|
|
83
|
+
await mkdir(join(repoRoot, 'server'), { recursive: true });
|
|
84
|
+
await writeFile(join(repoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
85
|
+
await writeFile(join(repoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
86
|
+
await writeFile(join(repoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
87
|
+
await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
|
|
88
|
+
|
|
89
|
+
const invokedCwd = join(repoRoot, 'cli', 'nested');
|
|
90
|
+
const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy', 'happy-cli', 'happy-server'] });
|
|
91
|
+
assert.deepEqual(inferred, { component: 'happy-cli', repoDir: repoRoot });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('inferComponentFromCwd returns null outside known component roots', async (t) => {
|
|
95
|
+
const rootDir = await withTempRoot(t);
|
|
96
|
+
const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
97
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
|
|
98
|
+
t.after(() => {
|
|
99
|
+
if (prevWorkspace == null) {
|
|
100
|
+
delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
101
|
+
} else {
|
|
102
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const invokedCwd = join(rootDir, 'somewhere', 'else');
|
|
107
|
+
await mkdir(invokedCwd, { recursive: true });
|
|
108
|
+
const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy'] });
|
|
109
|
+
assert.equal(inferred, null);
|
|
110
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
|
|
5
|
+
function splitLines(s) {
|
|
6
|
+
return String(s ?? '').split(/\r?\n/);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function supportsAnsi() {
|
|
10
|
+
if (!process.stdout.isTTY) return false;
|
|
11
|
+
if (process.env.NO_COLOR) return false;
|
|
12
|
+
if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function dim(s) {
|
|
17
|
+
return supportsAnsi() ? `\x1b[2m${s}\x1b[0m` : String(s);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lightweight file log forwarder (tail-like) with pause/resume.
|
|
22
|
+
*
|
|
23
|
+
* - Always advances the file offset (prevents backpressure issues).
|
|
24
|
+
* - While paused, it buffers the last N lines and prints them once resumed.
|
|
25
|
+
*/
|
|
26
|
+
export function createFileLogForwarder({
|
|
27
|
+
path,
|
|
28
|
+
enabled = true,
|
|
29
|
+
pollMs = 200,
|
|
30
|
+
maxBytesPerTick = 256 * 1024,
|
|
31
|
+
bufferedLinesWhilePaused = 120,
|
|
32
|
+
startFromEnd = true,
|
|
33
|
+
label = 'logs',
|
|
34
|
+
} = {}) {
|
|
35
|
+
const p = String(path ?? '').trim();
|
|
36
|
+
if (!enabled || !p) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
start: async () => {},
|
|
40
|
+
stop: async () => {},
|
|
41
|
+
pause: () => {},
|
|
42
|
+
resume: () => {},
|
|
43
|
+
isPaused: () => false,
|
|
44
|
+
path: p,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let running = false;
|
|
49
|
+
let paused = false;
|
|
50
|
+
let offset = 0;
|
|
51
|
+
let partial = '';
|
|
52
|
+
let buffered = [];
|
|
53
|
+
|
|
54
|
+
const pushBufferedLine = (line) => {
|
|
55
|
+
if (!line) return;
|
|
56
|
+
buffered.push(line);
|
|
57
|
+
if (buffered.length > bufferedLinesWhilePaused) {
|
|
58
|
+
buffered = buffered.slice(buffered.length - bufferedLinesWhilePaused);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const flushBuffered = () => {
|
|
63
|
+
if (!buffered.length) return;
|
|
64
|
+
// eslint-disable-next-line no-console
|
|
65
|
+
console.log(dim(`[${label}] (showing last ${buffered.length} lines while paused)`));
|
|
66
|
+
for (const l of buffered) {
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.log(l);
|
|
69
|
+
}
|
|
70
|
+
buffered = [];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const readNewBytes = async () => {
|
|
74
|
+
if (!existsSync(p)) return;
|
|
75
|
+
let st = null;
|
|
76
|
+
try {
|
|
77
|
+
st = await stat(p);
|
|
78
|
+
} catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const size = Number(st?.size ?? 0);
|
|
82
|
+
if (!Number.isFinite(size) || size <= 0) return;
|
|
83
|
+
if (size < offset) {
|
|
84
|
+
// truncated/rotated
|
|
85
|
+
offset = 0;
|
|
86
|
+
}
|
|
87
|
+
if (size === offset) return;
|
|
88
|
+
|
|
89
|
+
const end = Math.min(size, offset + maxBytesPerTick);
|
|
90
|
+
const start = offset;
|
|
91
|
+
offset = end;
|
|
92
|
+
|
|
93
|
+
await new Promise((resolvePromise) => {
|
|
94
|
+
const chunks = [];
|
|
95
|
+
const stream = createReadStream(p, { start, end: end - 1 });
|
|
96
|
+
stream.on('data', (d) => chunks.push(Buffer.from(d)));
|
|
97
|
+
stream.on('error', () => resolvePromise());
|
|
98
|
+
stream.on('close', () => {
|
|
99
|
+
const text = partial + Buffer.concat(chunks).toString('utf-8');
|
|
100
|
+
const lines = splitLines(text);
|
|
101
|
+
partial = lines.pop() ?? '';
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (paused) {
|
|
104
|
+
pushBufferedLine(line);
|
|
105
|
+
} else {
|
|
106
|
+
// eslint-disable-next-line no-console
|
|
107
|
+
console.log(line);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
resolvePromise();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const loop = async () => {
|
|
116
|
+
while (running) {
|
|
117
|
+
// eslint-disable-next-line no-await-in-loop
|
|
118
|
+
await readNewBytes();
|
|
119
|
+
// eslint-disable-next-line no-await-in-loop
|
|
120
|
+
await delay(pollMs);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
path: p,
|
|
127
|
+
start: async () => {
|
|
128
|
+
if (running) return;
|
|
129
|
+
running = true;
|
|
130
|
+
// By default, start at end (don't replay historical logs).
|
|
131
|
+
if (startFromEnd) {
|
|
132
|
+
try {
|
|
133
|
+
const st = await stat(p);
|
|
134
|
+
offset = Number(st?.size ?? 0) || 0;
|
|
135
|
+
} catch {
|
|
136
|
+
offset = 0;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
offset = 0;
|
|
140
|
+
}
|
|
141
|
+
void loop();
|
|
142
|
+
},
|
|
143
|
+
stop: async () => {
|
|
144
|
+
running = false;
|
|
145
|
+
},
|
|
146
|
+
pause: () => {
|
|
147
|
+
paused = true;
|
|
148
|
+
buffered = [];
|
|
149
|
+
},
|
|
150
|
+
resume: () => {
|
|
151
|
+
paused = false;
|
|
152
|
+
flushBuffered();
|
|
153
|
+
},
|
|
154
|
+
isPaused: () => paused,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { commandExists } from '../proc/commands.mjs';
|
|
2
|
+
|
|
3
|
+
function formatMissingTool({ name, why, install }) {
|
|
4
|
+
return [`- ${name}: ${why}`, ...(install?.length ? install.map((l) => ` ${l}`) : [])].join('\n');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function assertCliPrereqs({ git = false, pnpm = false, codex = false, coderabbit = false } = {}) {
|
|
8
|
+
const missing = [];
|
|
9
|
+
|
|
10
|
+
if (git) {
|
|
11
|
+
const hasGit = await commandExists('git');
|
|
12
|
+
if (!hasGit) {
|
|
13
|
+
const install =
|
|
14
|
+
process.platform === 'darwin'
|
|
15
|
+
? ['Install Xcode Command Line Tools: `xcode-select --install`', 'Or install Git via Homebrew: `brew install git`']
|
|
16
|
+
: ['Install Git using your package manager (e.g. `apt install git`, `dnf install git`)'];
|
|
17
|
+
missing.push({
|
|
18
|
+
name: 'git',
|
|
19
|
+
why: 'required for cloning + updating PR worktrees',
|
|
20
|
+
install,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (pnpm) {
|
|
26
|
+
const hasPnpm = await commandExists('pnpm');
|
|
27
|
+
const hasYarn = await commandExists('yarn');
|
|
28
|
+
if (!hasPnpm && !hasYarn) {
|
|
29
|
+
missing.push({
|
|
30
|
+
name: 'yarn/pnpm',
|
|
31
|
+
why: 'required to install dependencies for Happy Stacks components (varies per component)',
|
|
32
|
+
install: [
|
|
33
|
+
'Enable Corepack (recommended): `corepack enable`',
|
|
34
|
+
'Or install pnpm: `corepack prepare pnpm@latest --activate`',
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (codex) {
|
|
41
|
+
const hasCodex = await commandExists('codex');
|
|
42
|
+
if (!hasCodex) {
|
|
43
|
+
missing.push({
|
|
44
|
+
name: 'codex',
|
|
45
|
+
why: 'required to run Codex review',
|
|
46
|
+
install: [
|
|
47
|
+
'Install Codex CLI and ensure `codex` is on PATH',
|
|
48
|
+
'If using a managed install, ensure your PATH includes the Codex binary',
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (coderabbit) {
|
|
55
|
+
const hasCodeRabbit = await commandExists('coderabbit');
|
|
56
|
+
if (!hasCodeRabbit) {
|
|
57
|
+
missing.push({
|
|
58
|
+
name: 'coderabbit',
|
|
59
|
+
why: 'required to run CodeRabbit CLI review',
|
|
60
|
+
install: [
|
|
61
|
+
'Install CodeRabbit CLI: `curl -fsSL https://cli.coderabbit.ai/install.sh | sh`',
|
|
62
|
+
'Then authenticate: `coderabbit auth login`',
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!missing.length) return;
|
|
69
|
+
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[prereqs] missing required tools:\n` +
|
|
72
|
+
`${missing.map(formatMissingTool).join('\n')}\n\n` +
|
|
73
|
+
`[prereqs] After installing, re-run the command.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { assertCliPrereqs } from './prereqs.mjs';
|
|
8
|
+
|
|
9
|
+
test('assertCliPrereqs({pnpm:true}) accepts yarn when pnpm is missing', async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-prereqs-yarn-'));
|
|
11
|
+
const oldPath = process.env.PATH;
|
|
12
|
+
try {
|
|
13
|
+
const yarnBin = join(root, 'yarn');
|
|
14
|
+
await writeFile(yarnBin, '#!/bin/sh\nexit 0\n', 'utf-8');
|
|
15
|
+
await chmod(yarnBin, 0o755);
|
|
16
|
+
process.env.PATH = `/bin:${root}`;
|
|
17
|
+
|
|
18
|
+
await assertCliPrereqs({ pnpm: true });
|
|
19
|
+
} finally {
|
|
20
|
+
process.env.PATH = oldPath;
|
|
21
|
+
await rm(root, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('assertCliPrereqs({pnpm:true}) throws when neither pnpm nor yarn is available', async () => {
|
|
26
|
+
const oldPath = process.env.PATH;
|
|
27
|
+
try {
|
|
28
|
+
process.env.PATH = '/bin';
|
|
29
|
+
await assert.rejects(() => assertCliPrereqs({ pnpm: true }), /pnpm|yarn/i);
|
|
30
|
+
} finally {
|
|
31
|
+
process.env.PATH = oldPath;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
|
|
6
|
+
function isTty() {
|
|
7
|
+
return Boolean(process.stdout.isTTY && process.stderr.isTTY);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function spinnerFrames() {
|
|
11
|
+
return ['|', '/', '-', '\\'];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createStepPrinter({ enabled = true } = {}) {
|
|
15
|
+
const tty = enabled && isTty();
|
|
16
|
+
const frames = spinnerFrames();
|
|
17
|
+
let timer = null;
|
|
18
|
+
let idx = 0;
|
|
19
|
+
let currentLine = '';
|
|
20
|
+
|
|
21
|
+
const write = (s) => process.stdout.write(s);
|
|
22
|
+
|
|
23
|
+
const start = (label) => {
|
|
24
|
+
if (!tty) {
|
|
25
|
+
write(`- [..] ${label}\n`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
currentLine = `- [${frames[idx % frames.length]}] ${label}`;
|
|
29
|
+
write(currentLine);
|
|
30
|
+
timer = setInterval(() => {
|
|
31
|
+
idx++;
|
|
32
|
+
const next = `- [${frames[idx % frames.length]}] ${label}`;
|
|
33
|
+
const pad = currentLine.length > next.length ? ' '.repeat(currentLine.length - next.length) : '';
|
|
34
|
+
currentLine = next;
|
|
35
|
+
write(`\r${next}${pad}`);
|
|
36
|
+
}, 120);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const stop = (result, label) => {
|
|
40
|
+
if (timer) clearInterval(timer);
|
|
41
|
+
timer = null;
|
|
42
|
+
if (!tty) {
|
|
43
|
+
write(`- [${result}] ${label}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const out = `- [${result}] ${label}`;
|
|
47
|
+
const pad = currentLine.length > out.length ? ' '.repeat(currentLine.length - out.length) : '';
|
|
48
|
+
currentLine = '';
|
|
49
|
+
write(`\r${out}${pad}\n`);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const info = (line) => {
|
|
53
|
+
write(`${line}\n`);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return { start, stop, info };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runCommandLogged({
|
|
60
|
+
label,
|
|
61
|
+
cmd,
|
|
62
|
+
args,
|
|
63
|
+
cwd,
|
|
64
|
+
env,
|
|
65
|
+
logPath,
|
|
66
|
+
showSteps = true,
|
|
67
|
+
quiet = true,
|
|
68
|
+
}) {
|
|
69
|
+
const steps = createStepPrinter({ enabled: showSteps });
|
|
70
|
+
if (quiet) {
|
|
71
|
+
await mkdir(dirname(logPath), { recursive: true }).catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
steps.start(label);
|
|
75
|
+
|
|
76
|
+
const child = spawn(cmd, args, {
|
|
77
|
+
cwd,
|
|
78
|
+
env,
|
|
79
|
+
stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
80
|
+
shell: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
let stdout = '';
|
|
84
|
+
let stderr = '';
|
|
85
|
+
let logStream = null;
|
|
86
|
+
if (quiet) {
|
|
87
|
+
logStream = createWriteStream(logPath, { flags: 'a' });
|
|
88
|
+
child.stdout?.on('data', (d) => {
|
|
89
|
+
const s = d.toString();
|
|
90
|
+
stdout += s;
|
|
91
|
+
logStream?.write(s);
|
|
92
|
+
});
|
|
93
|
+
child.stderr?.on('data', (d) => {
|
|
94
|
+
const s = d.toString();
|
|
95
|
+
stderr += s;
|
|
96
|
+
logStream?.write(s);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const res = await new Promise((resolvePromise, rejectPromise) => {
|
|
101
|
+
child.on('error', rejectPromise);
|
|
102
|
+
child.on('close', (code, signal) => resolvePromise({ code: code ?? 1, signal: signal ?? null }));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
logStream?.end();
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (res.code === 0) {
|
|
112
|
+
steps.stop('✓', label);
|
|
113
|
+
return { ok: true, code: 0, stdout, stderr, logPath };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
steps.stop('x', label);
|
|
117
|
+
const err = new Error(`${cmd} failed (code=${res.code}${res.signal ? `, sig=${res.signal}` : ''})`);
|
|
118
|
+
err.code = 'EEXIT';
|
|
119
|
+
err.exitCode = res.code;
|
|
120
|
+
err.signal = res.signal;
|
|
121
|
+
err.stdout = stdout;
|
|
122
|
+
err.stderr = stderr;
|
|
123
|
+
err.logPath = logPath;
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getVerbosityLevel(env = process.env) {
|
|
2
|
+
const raw = (env.HAPPY_STACKS_VERBOSE ?? '').toString().trim();
|
|
3
|
+
if (!raw) return 0;
|
|
4
|
+
const n = Number(raw);
|
|
5
|
+
if (!Number.isFinite(n)) return 1;
|
|
6
|
+
return Math.max(0, Math.min(3, Math.floor(n)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isVerbose(env = process.env) {
|
|
10
|
+
return getVerbosityLevel(env) > 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -2,6 +2,9 @@ import { createInterface } from 'node:readline/promises';
|
|
|
2
2
|
import { listWorktreeSpecs } from '../git/worktrees.mjs';
|
|
3
3
|
|
|
4
4
|
export function isTty() {
|
|
5
|
+
if (process.env.HAPPY_STACKS_TEST_TTY === '1') {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
5
8
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
6
9
|
}
|
|
7
10
|
|
|
@@ -24,6 +27,8 @@ export async function promptSelect(rl, { title, options, defaultIndex = 0 }) {
|
|
|
24
27
|
throw new Error('[wizard] no options to select from');
|
|
25
28
|
}
|
|
26
29
|
// eslint-disable-next-line no-console
|
|
30
|
+
console.log('');
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
27
32
|
console.log(title);
|
|
28
33
|
for (let i = 0; i < options.length; i++) {
|
|
29
34
|
// eslint-disable-next-line no-console
|
|
@@ -35,22 +40,26 @@ export async function promptSelect(rl, { title, options, defaultIndex = 0 }) {
|
|
|
35
40
|
return options[idx].value;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
export async function promptWorktreeSource({ rl, rootDir, component, stackName, createRemote = 'upstream' }) {
|
|
39
|
-
const
|
|
43
|
+
export async function promptWorktreeSource({ rl, rootDir, component, stackName, createRemote = 'upstream', deps = {} }) {
|
|
44
|
+
const promptFn = deps.prompt ?? prompt;
|
|
45
|
+
const promptSelectFn = deps.promptSelect ?? promptSelect;
|
|
46
|
+
const listWorktreeSpecsFn = deps.listWorktreeSpecs ?? listWorktreeSpecs;
|
|
40
47
|
|
|
41
48
|
const baseOptions = [{ label: `default (components/${component})`, value: 'default' }];
|
|
42
|
-
|
|
43
|
-
baseOptions.push({ label: 'pick existing worktree', value: 'pick' });
|
|
44
|
-
}
|
|
49
|
+
baseOptions.push({ label: 'pick existing worktree', value: 'pick' });
|
|
45
50
|
baseOptions.push({ label: `create new worktree (${createRemote})`, value: 'create' });
|
|
46
51
|
|
|
47
|
-
const kind = await
|
|
52
|
+
const kind = await promptSelectFn(rl, { title: `Select ${component}:`, options: baseOptions, defaultIndex: 0 });
|
|
48
53
|
|
|
49
54
|
if (kind === 'default') {
|
|
50
55
|
return 'default';
|
|
51
56
|
}
|
|
52
57
|
if (kind === 'pick') {
|
|
53
|
-
const
|
|
58
|
+
const specs = await listWorktreeSpecsFn({ rootDir, component });
|
|
59
|
+
if (!specs.length) {
|
|
60
|
+
return 'default';
|
|
61
|
+
}
|
|
62
|
+
const picked = await promptSelectFn(rl, {
|
|
54
63
|
title: `Available ${component} worktrees:`,
|
|
55
64
|
options: specs.map((s) => ({ label: s, value: s })),
|
|
56
65
|
defaultIndex: 0,
|
|
@@ -58,7 +67,7 @@ export async function promptWorktreeSource({ rl, rootDir, component, stackName,
|
|
|
58
67
|
return picked;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
const slug = await
|
|
70
|
+
const slug = await promptFn(rl, `New worktree slug for ${component} (example: pr/${stackName}/${component}): `, {
|
|
62
71
|
defaultValue: '',
|
|
63
72
|
});
|
|
64
73
|
if (!slug) {
|
|
@@ -66,4 +75,3 @@ export async function promptWorktreeSource({ rl, rootDir, component, stackName,
|
|
|
66
75
|
}
|
|
67
76
|
return { create: true, slug, remote: createRemote };
|
|
68
77
|
}
|
|
69
|
-
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { promptWorktreeSource } from './wizard.mjs';
|
|
5
|
+
|
|
6
|
+
test('promptWorktreeSource does not list worktrees unless user selects "pick"', async () => {
|
|
7
|
+
let listed = 0;
|
|
8
|
+
const listWorktreeSpecs = async () => {
|
|
9
|
+
listed++;
|
|
10
|
+
return ['slopus/pr/123'];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const promptSelect = async () => 'default';
|
|
14
|
+
const prompt = async () => '';
|
|
15
|
+
|
|
16
|
+
const res = await promptWorktreeSource({
|
|
17
|
+
rl: {},
|
|
18
|
+
rootDir: '/tmp',
|
|
19
|
+
component: 'happy',
|
|
20
|
+
stackName: 'exp1',
|
|
21
|
+
createRemote: 'upstream',
|
|
22
|
+
deps: { listWorktreeSpecs, promptSelect, prompt },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
assert.equal(res, 'default');
|
|
26
|
+
assert.equal(listed, 0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('promptWorktreeSource lists worktrees when user selects "pick"', async () => {
|
|
30
|
+
let listed = 0;
|
|
31
|
+
const listWorktreeSpecs = async () => {
|
|
32
|
+
listed++;
|
|
33
|
+
return ['slopus/pr/123', 'slopus/pr/456'];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let selectCount = 0;
|
|
37
|
+
const promptSelect = async (_rl, { title }) => {
|
|
38
|
+
selectCount++;
|
|
39
|
+
if (selectCount === 1) {
|
|
40
|
+
assert.ok(title.startsWith('Select '));
|
|
41
|
+
return 'pick';
|
|
42
|
+
}
|
|
43
|
+
assert.ok(title.startsWith('Available '));
|
|
44
|
+
return 'slopus/pr/456';
|
|
45
|
+
};
|
|
46
|
+
const prompt = async () => '';
|
|
47
|
+
|
|
48
|
+
const res = await promptWorktreeSource({
|
|
49
|
+
rl: {},
|
|
50
|
+
rootDir: '/tmp',
|
|
51
|
+
component: 'happy',
|
|
52
|
+
stackName: 'exp1',
|
|
53
|
+
createRemote: 'upstream',
|
|
54
|
+
deps: { listWorktreeSpecs, promptSelect, prompt },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.equal(res, 'slopus/pr/456');
|
|
58
|
+
assert.equal(listed, 1);
|
|
59
|
+
});
|
|
60
|
+
|