happy-stacks 0.6.11 → 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 +1297 -140
- 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/import.mjs +19 -13
- package/scripts/monorepo.mjs +414 -23
- package/scripts/monorepo_port.test.mjs +136 -0
- 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/cli/wizard.mjs +19 -1
- package/scripts/utils/dev/daemon.mjs +3 -1
- package/scripts/utils/llm/assist.mjs +64 -31
- package/scripts/utils/llm/happys_runner.mjs +60 -0
- package/scripts/utils/llm/tools.mjs +3 -3
- 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
|
@@ -1,59 +1,26 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import { parseCodeRabbitRateLimitRetryMs } from './coderabbit.mjs';
|
|
6
5
|
|
|
7
|
-
test('
|
|
8
|
-
|
|
9
|
-
const args = buildCodeRabbitReviewArgs({ repoDir, baseRef: 'upstream/main', type: undefined, configFiles: [] });
|
|
10
|
-
assert.deepEqual(args, ['review', '--plain', '--no-color', '--type', 'committed', '--cwd', repoDir, '--base', 'upstream/main']);
|
|
6
|
+
test('parseCodeRabbitRateLimitRetryMs returns null when no rate limit message is present', () => {
|
|
7
|
+
assert.equal(parseCodeRabbitRateLimitRetryMs('Review completed ✔'), null);
|
|
11
8
|
});
|
|
12
9
|
|
|
13
|
-
test('
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
assert.deepEqual(args, ['review', '--plain', '--no-color', '--type', 'committed', '--cwd', repoDir, '--base-commit', 'abc123']);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('buildCodeRabbitReviewArgs rejects providing both baseRef and baseCommit', () => {
|
|
20
|
-
assert.throws(
|
|
21
|
-
() => buildCodeRabbitReviewArgs({ repoDir: '/tmp/repo', baseRef: 'upstream/main', baseCommit: 'abc123', type: 'committed', configFiles: [] }),
|
|
22
|
-
/mutually exclusive/
|
|
10
|
+
test('parseCodeRabbitRateLimitRetryMs parses the suggested retry delay', () => {
|
|
11
|
+
const ms = parseCodeRabbitRateLimitRetryMs(
|
|
12
|
+
'[2026-01-25T22:29:41.623Z] ❌ ERROR: Error: Rate limit exceeded, please try after 3 minutes and 2 seconds'
|
|
23
13
|
);
|
|
14
|
+
assert.ok(ms);
|
|
15
|
+
// Allow +1s padding.
|
|
16
|
+
assert.equal(ms, (3 * 60 + 2 + 1) * 1000);
|
|
24
17
|
});
|
|
25
18
|
|
|
26
|
-
test('
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
configFiles: ['/tmp/a.md', '/tmp/b.md'],
|
|
33
|
-
});
|
|
34
|
-
assert.deepEqual(args, [
|
|
35
|
-
'review',
|
|
36
|
-
'--plain',
|
|
37
|
-
'--no-color',
|
|
38
|
-
'--type',
|
|
39
|
-
'committed',
|
|
40
|
-
'--cwd',
|
|
41
|
-
repoDir,
|
|
42
|
-
'--base',
|
|
43
|
-
'upstream/main',
|
|
44
|
-
'--config',
|
|
45
|
-
'/tmp/a.md',
|
|
46
|
-
'/tmp/b.md',
|
|
47
|
-
]);
|
|
19
|
+
test('parseCodeRabbitRateLimitRetryMs supports seconds-only windows', () => {
|
|
20
|
+
const ms = parseCodeRabbitRateLimitRetryMs(
|
|
21
|
+
'[2026-01-26T00:27:23.067Z] ❌ ERROR: Error: Rate limit exceeded, please try after 0 minutes and 31 seconds'
|
|
22
|
+
);
|
|
23
|
+
assert.ok(ms);
|
|
24
|
+
assert.equal(ms, (31 + 1) * 1000);
|
|
48
25
|
});
|
|
49
26
|
|
|
50
|
-
test('buildCodeRabbitEnv overrides HOME/XDG paths when a homeDir is provided', () => {
|
|
51
|
-
const env = buildCodeRabbitEnv({ env: { PATH: '/bin' }, homeDir: '/tmp/cr-home' });
|
|
52
|
-
assert.equal(env.PATH, '/bin');
|
|
53
|
-
assert.equal(env.HOME, '/tmp/cr-home');
|
|
54
|
-
assert.equal(env.CODERABBIT_HOME, join('/tmp/cr-home', '.coderabbit'));
|
|
55
|
-
assert.equal(env.XDG_CONFIG_HOME, join('/tmp/cr-home', '.config'));
|
|
56
|
-
assert.equal(env.XDG_CACHE_HOME, join('/tmp/cr-home', '.cache'));
|
|
57
|
-
assert.equal(env.XDG_STATE_HOME, join('/tmp/cr-home', '.local', 'state'));
|
|
58
|
-
assert.equal(env.XDG_DATA_HOME, join('/tmp/cr-home', '.local', 'share'));
|
|
59
|
-
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { runWithConcurrencyLimit } from '../proc/parallel.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a list of "slice jobs" with:
|
|
5
|
+
* - a mandatory sequential first job (preflight)
|
|
6
|
+
* - parallel execution for the remainder (bounded by `limit`)
|
|
7
|
+
* - stable, input-order results
|
|
8
|
+
* - optional early-abort after the first job (e.g. auth/credits missing)
|
|
9
|
+
*/
|
|
10
|
+
export async function runSlicedJobs({ items, limit = 1, run, shouldAbortEarly } = {}) {
|
|
11
|
+
const list = Array.isArray(items) ? items : [];
|
|
12
|
+
if (!list.length) return [];
|
|
13
|
+
if (typeof run !== 'function') throw new Error('[review] runSlicedJobs: missing run()');
|
|
14
|
+
|
|
15
|
+
const concurrency = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 1;
|
|
16
|
+
|
|
17
|
+
const out = [];
|
|
18
|
+
// Always run the first item sequentially so we can fail fast on auth/credits problems
|
|
19
|
+
// before spinning up many long-running review jobs.
|
|
20
|
+
// eslint-disable-next-line no-await-in-loop
|
|
21
|
+
const firstRes = await run(list[0]);
|
|
22
|
+
out.push(firstRes);
|
|
23
|
+
if (typeof shouldAbortEarly === 'function' && shouldAbortEarly(firstRes)) {
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (list.length === 1) return out;
|
|
28
|
+
|
|
29
|
+
const rest = list.slice(1);
|
|
30
|
+
const restRes = await runWithConcurrencyLimit({
|
|
31
|
+
items: rest,
|
|
32
|
+
limit: concurrency,
|
|
33
|
+
fn: async (item) => await run(item),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Preserve input order.
|
|
37
|
+
return [...out, ...restRes];
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runSlicedJobs } from './sliced_runner.mjs';
|
|
4
|
+
|
|
5
|
+
test('runSlicedJobs preserves order and respects concurrency', async () => {
|
|
6
|
+
const items = Array.from({ length: 7 }, (_, i) => ({ index: i + 1 }));
|
|
7
|
+
|
|
8
|
+
let active = 0;
|
|
9
|
+
let maxActive = 0;
|
|
10
|
+
|
|
11
|
+
const results = await runSlicedJobs({
|
|
12
|
+
items,
|
|
13
|
+
limit: 2,
|
|
14
|
+
run: async (item) => {
|
|
15
|
+
active += 1;
|
|
16
|
+
maxActive = Math.max(maxActive, active);
|
|
17
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
18
|
+
active -= 1;
|
|
19
|
+
return { index: item.index };
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.equal(maxActive, 2);
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
results.map((r) => r.index),
|
|
26
|
+
items.map((i) => i.index)
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('runSlicedJobs can abort early after the first item', async () => {
|
|
31
|
+
const items = Array.from({ length: 5 }, (_, i) => ({ index: i + 1 }));
|
|
32
|
+
const seen = [];
|
|
33
|
+
|
|
34
|
+
const results = await runSlicedJobs({
|
|
35
|
+
items,
|
|
36
|
+
limit: 3,
|
|
37
|
+
run: async (item) => {
|
|
38
|
+
seen.push(item.index);
|
|
39
|
+
return { index: item.index, abort: item.index === 1 };
|
|
40
|
+
},
|
|
41
|
+
shouldAbortEarly: (res) => Boolean(res?.abort),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(seen, [1]);
|
|
45
|
+
assert.deepEqual(results.map((r) => r.index), [1]);
|
|
46
|
+
});
|
|
47
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { copyFile, cp, mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
async function mergeCopyDir({ srcDir, destDir }) {
|
|
6
|
+
if (!existsSync(srcDir)) return;
|
|
7
|
+
await mkdir(destDir, { recursive: true });
|
|
8
|
+
await cp(srcDir, destDir, { recursive: true, force: false, errorOnExist: false });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function copyFileIfNewer({ srcFile, destFile }) {
|
|
12
|
+
if (!existsSync(srcFile)) return;
|
|
13
|
+
try {
|
|
14
|
+
const srcStat = await stat(srcFile);
|
|
15
|
+
let destStat = null;
|
|
16
|
+
try {
|
|
17
|
+
destStat = await stat(destFile);
|
|
18
|
+
} catch {
|
|
19
|
+
destStat = null;
|
|
20
|
+
}
|
|
21
|
+
if (!destStat || srcStat.mtimeMs > destStat.mtimeMs) {
|
|
22
|
+
await mkdir(join(destFile, '..'), { recursive: true });
|
|
23
|
+
await copyFile(srcFile, destFile);
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// best-effort
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort: seed CodeRabbit auth/config into an isolated home directory.
|
|
32
|
+
*
|
|
33
|
+
* We do not read or print any auth contents; we only copy the on-disk state when present.
|
|
34
|
+
*/
|
|
35
|
+
export async function seedCodeRabbitHomeFromRealHome({ realHomeDir, isolatedHomeDir } = {}) {
|
|
36
|
+
const real = String(realHomeDir ?? '').trim();
|
|
37
|
+
const isolated = String(isolatedHomeDir ?? '').trim();
|
|
38
|
+
if (!real || !isolated || real === isolated) return;
|
|
39
|
+
|
|
40
|
+
// Common CodeRabbit state locations:
|
|
41
|
+
// - ~/.coderabbit/
|
|
42
|
+
// - ~/.config/coderabbit/ (XDG config)
|
|
43
|
+
// - ~/.cache/coderabbit/ (XDG cache)
|
|
44
|
+
// - ~/.local/share/coderabbit/ (XDG data)
|
|
45
|
+
// - ~/.local/state/coderabbit/ (XDG state)
|
|
46
|
+
//
|
|
47
|
+
// We merge-copy without overwriting existing files so a user can explicitly
|
|
48
|
+
// auth in the isolated dir and we won't clobber it.
|
|
49
|
+
await mergeCopyDir({ srcDir: join(real, '.coderabbit'), destDir: join(isolated, '.coderabbit') });
|
|
50
|
+
await mergeCopyDir({ srcDir: join(real, '.config', 'coderabbit'), destDir: join(isolated, '.config', 'coderabbit') });
|
|
51
|
+
await mergeCopyDir({ srcDir: join(real, '.cache', 'coderabbit'), destDir: join(isolated, '.cache', 'coderabbit') });
|
|
52
|
+
await mergeCopyDir({ srcDir: join(real, '.local', 'share', 'coderabbit'), destDir: join(isolated, '.local', 'share', 'coderabbit') });
|
|
53
|
+
await mergeCopyDir({ srcDir: join(real, '.local', 'state', 'coderabbit'), destDir: join(isolated, '.local', 'state', 'coderabbit') });
|
|
54
|
+
|
|
55
|
+
// If the user re-authenticated recently, refresh auth.json even when it already exists.
|
|
56
|
+
await copyFileIfNewer({
|
|
57
|
+
srcFile: join(real, '.coderabbit', 'auth.json'),
|
|
58
|
+
destFile: join(isolated, '.coderabbit', 'auth.json'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Best-effort: seed Codex auth/config into an isolated CODEX_HOME directory.
|
|
64
|
+
*
|
|
65
|
+
* Codex stores auth/config under `CODEX_HOME` (default: ~/.codex). In stack review runs we
|
|
66
|
+
* use a per-repo isolated home (e.g. .project/codex-home) to avoid polluting ~/.codex and
|
|
67
|
+
* to keep sandboxed runs self-contained.
|
|
68
|
+
*
|
|
69
|
+
* We do not read or print any auth contents; we only copy the on-disk state when present.
|
|
70
|
+
*/
|
|
71
|
+
export async function seedCodexHomeFromRealHome({ realHomeDir, isolatedHomeDir } = {}) {
|
|
72
|
+
const real = String(realHomeDir ?? '').trim();
|
|
73
|
+
const isolated = String(isolatedHomeDir ?? '').trim();
|
|
74
|
+
if (!real || !isolated || real === isolated) return;
|
|
75
|
+
|
|
76
|
+
// Codex uses CODEX_HOME/{auth.json,config.toml,...}
|
|
77
|
+
await copyFileIfNewer({ srcFile: join(real, '.codex', 'auth.json'), destFile: join(isolated, 'auth.json') });
|
|
78
|
+
await copyFileIfNewer({ srcFile: join(real, '.codex', 'config.toml'), destFile: join(isolated, 'config.toml') });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Best-effort: seed Auggie (Augment CLI) auth/config into an isolated cache directory.
|
|
83
|
+
*
|
|
84
|
+
* Auggie uses `~/.augment` by default (see `--augment-cache-dir`), and supports
|
|
85
|
+
* providing session auth via `AUGMENT_SESSION_AUTH` (same format as `~/.augment/session.json`).
|
|
86
|
+
*
|
|
87
|
+
* We do not read or print any auth contents; we only copy the on-disk state when present.
|
|
88
|
+
*/
|
|
89
|
+
export async function seedAugmentHomeFromRealHome({ realHomeDir, isolatedHomeDir } = {}) {
|
|
90
|
+
const real = String(realHomeDir ?? '').trim();
|
|
91
|
+
const isolated = String(isolatedHomeDir ?? '').trim();
|
|
92
|
+
if (!real || !isolated || real === isolated) return;
|
|
93
|
+
|
|
94
|
+
// Copy ~/.augment/* into the isolated cache dir.
|
|
95
|
+
await mergeCopyDir({ srcDir: join(real, '.augment'), destDir: isolated });
|
|
96
|
+
|
|
97
|
+
// Refresh session.json when it is newer.
|
|
98
|
+
await copyFileIfNewer({ srcFile: join(real, '.augment', 'session.json'), destFile: join(isolated, 'session.json') });
|
|
99
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { seedAugmentHomeFromRealHome, seedCodeRabbitHomeFromRealHome, seedCodexHomeFromRealHome } from './tool_home_seed.mjs';
|
|
7
|
+
|
|
8
|
+
test('seedCodeRabbitHomeFromRealHome copies coderabbit state into isolated home when missing', async () => {
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), 'happy-stacks-coderabbit-seed-'));
|
|
10
|
+
const realHome = join(root, 'real');
|
|
11
|
+
const isolatedHome = join(root, 'isolated');
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await mkdir(join(realHome, '.coderabbit'), { recursive: true });
|
|
15
|
+
await mkdir(join(realHome, '.config', 'coderabbit'), { recursive: true });
|
|
16
|
+
await writeFile(join(realHome, '.coderabbit', 'auth.json'), 'secret-ish\n', 'utf-8');
|
|
17
|
+
await writeFile(join(realHome, '.config', 'coderabbit', 'config.toml'), 'cfg\n', 'utf-8');
|
|
18
|
+
|
|
19
|
+
await mkdir(isolatedHome, { recursive: true });
|
|
20
|
+
|
|
21
|
+
await seedCodeRabbitHomeFromRealHome({ realHomeDir: realHome, isolatedHomeDir: isolatedHome });
|
|
22
|
+
|
|
23
|
+
assert.equal(await readFile(join(isolatedHome, '.coderabbit', 'auth.json'), 'utf-8'), 'secret-ish\n');
|
|
24
|
+
assert.equal(await readFile(join(isolatedHome, '.config', 'coderabbit', 'config.toml'), 'utf-8'), 'cfg\n');
|
|
25
|
+
} finally {
|
|
26
|
+
await rm(root, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('seedCodeRabbitHomeFromRealHome does not overwrite existing isolated state', async () => {
|
|
31
|
+
const root = await mkdtemp(join(tmpdir(), 'happy-stacks-coderabbit-seed-'));
|
|
32
|
+
const realHome = join(root, 'real');
|
|
33
|
+
const isolatedHome = join(root, 'isolated');
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await mkdir(join(realHome, '.coderabbit'), { recursive: true });
|
|
37
|
+
await writeFile(join(realHome, '.coderabbit', 'auth.json'), 'from-real\n', 'utf-8');
|
|
38
|
+
|
|
39
|
+
await mkdir(join(isolatedHome, '.coderabbit'), { recursive: true });
|
|
40
|
+
await writeFile(join(isolatedHome, '.coderabbit', 'auth.json'), 'already\n', 'utf-8');
|
|
41
|
+
|
|
42
|
+
await seedCodeRabbitHomeFromRealHome({ realHomeDir: realHome, isolatedHomeDir: isolatedHome });
|
|
43
|
+
|
|
44
|
+
assert.equal(await readFile(join(isolatedHome, '.coderabbit', 'auth.json'), 'utf-8'), 'already\n');
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(root, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('seedCodeRabbitHomeFromRealHome refreshes auth.json when the real home has a newer one', async () => {
|
|
51
|
+
const root = await mkdtemp(join(tmpdir(), 'happy-stacks-coderabbit-seed-'));
|
|
52
|
+
const realHome = join(root, 'real');
|
|
53
|
+
const isolatedHome = join(root, 'isolated');
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await mkdir(join(realHome, '.coderabbit'), { recursive: true });
|
|
57
|
+
await mkdir(join(isolatedHome, '.coderabbit'), { recursive: true });
|
|
58
|
+
|
|
59
|
+
await writeFile(join(isolatedHome, '.coderabbit', 'auth.json'), 'old\n', 'utf-8');
|
|
60
|
+
// Ensure a later mtime for the real auth.
|
|
61
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
62
|
+
await writeFile(join(realHome, '.coderabbit', 'auth.json'), 'new\n', 'utf-8');
|
|
63
|
+
|
|
64
|
+
await seedCodeRabbitHomeFromRealHome({ realHomeDir: realHome, isolatedHomeDir: isolatedHome });
|
|
65
|
+
|
|
66
|
+
assert.equal(await readFile(join(isolatedHome, '.coderabbit', 'auth.json'), 'utf-8'), 'new\n');
|
|
67
|
+
} finally {
|
|
68
|
+
await rm(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('seedCodexHomeFromRealHome refreshes auth/config when the real home has newer ones', async () => {
|
|
73
|
+
const root = await mkdtemp(join(tmpdir(), 'happy-stacks-codex-seed-'));
|
|
74
|
+
const realHome = join(root, 'real');
|
|
75
|
+
const isolatedHome = join(root, 'isolated');
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(join(realHome, '.codex'), { recursive: true });
|
|
79
|
+
await mkdir(isolatedHome, { recursive: true });
|
|
80
|
+
|
|
81
|
+
await writeFile(join(isolatedHome, 'auth.json'), 'old-auth\n', 'utf-8');
|
|
82
|
+
await writeFile(join(isolatedHome, 'config.toml'), 'old-cfg\n', 'utf-8');
|
|
83
|
+
|
|
84
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
85
|
+
await writeFile(join(realHome, '.codex', 'auth.json'), 'new-auth\n', 'utf-8');
|
|
86
|
+
await writeFile(join(realHome, '.codex', 'config.toml'), 'new-cfg\n', 'utf-8');
|
|
87
|
+
|
|
88
|
+
await seedCodexHomeFromRealHome({ realHomeDir: realHome, isolatedHomeDir: isolatedHome });
|
|
89
|
+
|
|
90
|
+
assert.equal(await readFile(join(isolatedHome, 'auth.json'), 'utf-8'), 'new-auth\n');
|
|
91
|
+
assert.equal(await readFile(join(isolatedHome, 'config.toml'), 'utf-8'), 'new-cfg\n');
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(root, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('seedAugmentHomeFromRealHome copies session.json into isolated cache dir', async () => {
|
|
98
|
+
const root = await mkdtemp(join(tmpdir(), 'happy-stacks-augment-seed-'));
|
|
99
|
+
const realHome = join(root, 'real');
|
|
100
|
+
const isolatedHome = join(root, 'isolated');
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(join(realHome, '.augment'), { recursive: true });
|
|
104
|
+
await writeFile(join(realHome, '.augment', 'session.json'), '{"ok":true}\n', 'utf-8');
|
|
105
|
+
await mkdir(isolatedHome, { recursive: true });
|
|
106
|
+
|
|
107
|
+
await seedAugmentHomeFromRealHome({ realHomeDir: realHome, isolatedHomeDir: isolatedHome });
|
|
108
|
+
|
|
109
|
+
assert.equal(await readFile(join(isolatedHome, 'session.json'), 'utf-8'), '{"ok":true}\n');
|
|
110
|
+
} finally {
|
|
111
|
+
await rm(root, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const IDENTITY_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
4
|
+
|
|
5
|
+
export function parseCliIdentityOrThrow(raw) {
|
|
6
|
+
const v = (raw ?? '').toString().trim();
|
|
7
|
+
if (!v || v === 'default') return 'default';
|
|
8
|
+
if (!IDENTITY_RE.test(v)) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`[stack] invalid --identity=${JSON.stringify(v)}. ` +
|
|
11
|
+
`Expected: "default" or a short name matching ${IDENTITY_RE} (max 64 chars).`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
return v;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveCliHomeDirForIdentity({ cliHomeDir, identity }) {
|
|
18
|
+
const id = parseCliIdentityOrThrow(identity);
|
|
19
|
+
if (id === 'default') return cliHomeDir;
|
|
20
|
+
|
|
21
|
+
// Keep identities adjacent to the stack's default cli home dir:
|
|
22
|
+
// <...>/<stack>/cli (default)
|
|
23
|
+
// <...>/<stack>/cli-identities/<id>
|
|
24
|
+
//
|
|
25
|
+
// If the stack overrides cliHomeDir to a custom path, we keep the same layout
|
|
26
|
+
// relative to that path's parent directory.
|
|
27
|
+
const baseDir = dirname(cliHomeDir);
|
|
28
|
+
return join(baseDir, 'cli-identities', id);
|
|
29
|
+
}
|
|
@@ -16,6 +16,11 @@ function looksLikeAlreadyExistsError(msg) {
|
|
|
16
16
|
return s.includes('already exists') || s.includes('duplicate') || s.includes('constraint failed');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function looksLikeDatabaseLockedError(msg) {
|
|
20
|
+
const s = String(msg ?? '').toLowerCase();
|
|
21
|
+
return s.includes('database is locked') || s.includes('sqlite database error');
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
function looksLikeMissingGeneratedSqliteClientError(err) {
|
|
20
25
|
const code = err && typeof err === 'object' ? err.code : '';
|
|
21
26
|
if (code !== 'ERR_MODULE_NOT_FOUND') return false;
|
|
@@ -123,7 +128,7 @@ export function resolveAuthSeedFromEnv(env) {
|
|
|
123
128
|
return seed || 'main';
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
export async function ensureServerLightSchemaReady({ serverDir, env }) {
|
|
131
|
+
export async function ensureServerLightSchemaReady({ serverDir, env, bestEffort = false }) {
|
|
127
132
|
await ensureDepsInstalled(serverDir, 'happy-server-light', { env });
|
|
128
133
|
|
|
129
134
|
const dataDir = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').toString().trim();
|
|
@@ -151,11 +156,19 @@ export async function ensureServerLightSchemaReady({ serverDir, env }) {
|
|
|
151
156
|
// Unified server-light (monorepo): ensure deterministic migrations are applied (idempotent).
|
|
152
157
|
// Legacy server-light (single schema.prisma with db push): do NOT run `prisma migrate deploy`,
|
|
153
158
|
// because it commonly fails with P3005 when the DB was created by `prisma db push` and no migrations exist.
|
|
154
|
-
|
|
159
|
+
//
|
|
160
|
+
// IMPORTANT:
|
|
161
|
+
// In dev/start flows the server process may already be running and holding the SQLite DB open.
|
|
162
|
+
// Running `prisma migrate deploy` concurrently will fail with "database is locked".
|
|
163
|
+
// When bestEffort=true (used for auth seeding heuristics), skip migrations and only probe.
|
|
164
|
+
if (isUnified && !bestEffort) {
|
|
155
165
|
try {
|
|
156
166
|
await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
|
|
157
167
|
} catch (e) {
|
|
158
168
|
const msg = e instanceof Error ? e.message : String(e);
|
|
169
|
+
if (looksLikeDatabaseLockedError(msg) && bestEffort) {
|
|
170
|
+
return { ok: false, migrated: true, accountCount: null, error: msg };
|
|
171
|
+
}
|
|
159
172
|
// If the SQLite DB was created before migrations existed (historical db push era),
|
|
160
173
|
// `migrate deploy` can fail because tables already exist. Best-effort: baseline-resolve
|
|
161
174
|
// the first migration, then retry deploy.
|
|
@@ -185,13 +198,27 @@ export async function ensureServerLightSchemaReady({ serverDir, env }) {
|
|
|
185
198
|
} catch (e) {
|
|
186
199
|
if (looksLikeMissingGeneratedSqliteClientError(e)) {
|
|
187
200
|
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['generate', ...schemaArgs], env });
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
try {
|
|
202
|
+
const accountCount = await probe();
|
|
203
|
+
return { ok: true, migrated: isUnified, accountCount };
|
|
204
|
+
} catch (e2) {
|
|
205
|
+
const msg = e2 instanceof Error ? e2.message : String(e2);
|
|
206
|
+
if (bestEffort && looksLikeDatabaseLockedError(msg)) {
|
|
207
|
+
return { ok: false, migrated: isUnified, accountCount: null, error: msg };
|
|
208
|
+
}
|
|
209
|
+
throw e2;
|
|
210
|
+
}
|
|
190
211
|
}
|
|
191
212
|
const msg = e instanceof Error ? e.message : String(e);
|
|
213
|
+
if (bestEffort && looksLikeDatabaseLockedError(msg)) {
|
|
214
|
+
return { ok: false, migrated: isUnified, accountCount: null, error: msg };
|
|
215
|
+
}
|
|
192
216
|
if (looksLikeMissingTableError(msg)) {
|
|
193
217
|
if (isUnified) {
|
|
194
|
-
// Tables still missing after migrate deploy; fail closed
|
|
218
|
+
// Tables still missing after migrate deploy (or probe without migrations); fail closed unless best-effort.
|
|
219
|
+
if (bestEffort) {
|
|
220
|
+
return { ok: false, migrated: true, accountCount: null, error: 'sqlite schema not ready (missing tables)' };
|
|
221
|
+
}
|
|
195
222
|
throw new Error(`[server-light] sqlite schema not ready after prisma migrate deploy (missing tables).`);
|
|
196
223
|
}
|
|
197
224
|
// Legacy server-light: schema is typically applied via `prisma db push` in the component's dev/start scripts.
|
|
@@ -202,6 +229,9 @@ export async function ensureServerLightSchemaReady({ serverDir, env }) {
|
|
|
202
229
|
// Legacy server-light: probing is best-effort (don't make stack dev fail closed here).
|
|
203
230
|
return { ok: true, migrated: false, accountCount: 0 };
|
|
204
231
|
}
|
|
232
|
+
if (bestEffort) {
|
|
233
|
+
return { ok: false, migrated: true, accountCount: null, error: msg };
|
|
234
|
+
}
|
|
205
235
|
throw e;
|
|
206
236
|
}
|
|
207
237
|
}
|
|
@@ -226,8 +256,16 @@ export async function ensureHappyServerSchemaReady({ serverDir, env }) {
|
|
|
226
256
|
|
|
227
257
|
export async function getAccountCountForServerComponent({ serverComponentName, serverDir, env, bestEffort = false }) {
|
|
228
258
|
if (serverComponentName === 'happy-server-light') {
|
|
229
|
-
|
|
230
|
-
|
|
259
|
+
try {
|
|
260
|
+
const ready = await ensureServerLightSchemaReady({ serverDir, env, bestEffort });
|
|
261
|
+
if (!ready?.ok) {
|
|
262
|
+
return { ok: false, accountCount: null, error: String(ready?.error ?? 'server-light schema probe failed') };
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, accountCount: Number.isFinite(ready.accountCount) ? ready.accountCount : 0 };
|
|
265
|
+
} catch (e) {
|
|
266
|
+
if (!bestEffort) throw e;
|
|
267
|
+
return { ok: false, accountCount: null, error: e instanceof Error ? e.message : String(e) };
|
|
268
|
+
}
|
|
231
269
|
}
|
|
232
270
|
if (serverComponentName === 'happy-server') {
|
|
233
271
|
try {
|
package/scripts/worktrees.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
|
26
26
|
import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
|
|
27
27
|
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
28
28
|
import { isSandboxed } from './utils/env/sandbox.mjs';
|
|
29
|
+
import { applyStackCacheEnv } from './utils/proc/pm.mjs';
|
|
29
30
|
import { existsSync } from 'node:fs';
|
|
30
31
|
import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
|
|
31
32
|
import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
|
|
@@ -417,6 +418,8 @@ async function installDependencies({ dir }) {
|
|
|
417
418
|
return { installed: false, reason: 'no package manager detected (no package.json)' };
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
const env = await applyStackCacheEnv(process.env);
|
|
422
|
+
|
|
420
423
|
// IMPORTANT:
|
|
421
424
|
// When a caller requests --json, stdout must be reserved for JSON output only.
|
|
422
425
|
// Package managers (especially Yarn) write progress to stdout, which would corrupt JSON parsing
|
|
@@ -424,7 +427,7 @@ async function installDependencies({ dir }) {
|
|
|
424
427
|
const jsonMode = Boolean((process.argv ?? []).includes('--json'));
|
|
425
428
|
const runForJson = async (cmd, args) => {
|
|
426
429
|
try {
|
|
427
|
-
const out = await runCapture(cmd, args, { cwd: dir });
|
|
430
|
+
const out = await runCapture(cmd, args, { cwd: dir, env });
|
|
428
431
|
if (out) process.stderr.write(out);
|
|
429
432
|
} catch (e) {
|
|
430
433
|
const out = String(e?.out ?? '');
|
|
@@ -439,7 +442,7 @@ async function installDependencies({ dir }) {
|
|
|
439
442
|
if (jsonMode) {
|
|
440
443
|
await runForJson('pnpm', ['install', '--frozen-lockfile']);
|
|
441
444
|
} else {
|
|
442
|
-
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
|
|
445
|
+
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir, env });
|
|
443
446
|
}
|
|
444
447
|
return { installed: true, reason: null };
|
|
445
448
|
}
|
|
@@ -448,7 +451,7 @@ async function installDependencies({ dir }) {
|
|
|
448
451
|
if (jsonMode) {
|
|
449
452
|
await runForJson('yarn', ['install', '--frozen-lockfile']);
|
|
450
453
|
} else {
|
|
451
|
-
await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir });
|
|
454
|
+
await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir, env });
|
|
452
455
|
}
|
|
453
456
|
return { installed: true, reason: null };
|
|
454
457
|
}
|
|
@@ -457,13 +460,13 @@ async function installDependencies({ dir }) {
|
|
|
457
460
|
if (jsonMode) {
|
|
458
461
|
await runForJson('npm', ['ci']);
|
|
459
462
|
} else {
|
|
460
|
-
await run('npm', ['ci'], { cwd: dir });
|
|
463
|
+
await run('npm', ['ci'], { cwd: dir, env });
|
|
461
464
|
}
|
|
462
465
|
} else {
|
|
463
466
|
if (jsonMode) {
|
|
464
467
|
await runForJson('npm', ['install']);
|
|
465
468
|
} else {
|
|
466
|
-
await run('npm', ['install'], { cwd: dir });
|
|
469
|
+
await run('npm', ['install'], { cwd: dir, env });
|
|
467
470
|
}
|
|
468
471
|
}
|
|
469
472
|
return { installed: true, reason: null };
|