happy-stacks 0.4.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 +64 -33
- package/bin/happys.mjs +44 -1
- 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 +1 -2
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +1 -1
- package/scripts/auth.mjs +21 -3
- package/scripts/build.mjs +1 -1
- package/scripts/dev.mjs +20 -7
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +2 -2
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +5 -2
- package/scripts/install.mjs +99 -57
- package/scripts/migrate.mjs +3 -12
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/review.mjs +715 -24
- package/scripts/review_pr.mjs +5 -20
- package/scripts/run.mjs +21 -15
- package/scripts/setup.mjs +147 -25
- package/scripts/setup_pr.mjs +19 -28
- package/scripts/stack.mjs +493 -157
- 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/tui.mjs +88 -2
- package/scripts/utils/cli/cli_registry.mjs +20 -5
- package/scripts/utils/cli/cwd_scope.mjs +56 -2
- package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
- package/scripts/utils/cli/prereqs.mjs +8 -5
- package/scripts/utils/cli/prereqs.test.mjs +34 -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 +14 -1
- package/scripts/utils/dev/expo_dev.mjs +188 -4
- package/scripts/utils/dev/server.mjs +21 -17
- 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/git/worktrees.mjs +63 -12
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/paths/paths.mjs +118 -3
- 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/pm.mjs +113 -16
- 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 +68 -10
- package/scripts/utils/proc/proc.test.mjs +77 -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 +56 -14
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +32 -22
- 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/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -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/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/stack/editor_workspace.mjs +4 -4
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/startup.mjs +113 -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/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +627 -137
- 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
|
@@ -3,17 +3,51 @@ import { ensureDepsInstalled, pmExecBin } from '../proc/pm.mjs';
|
|
|
3
3
|
import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { mkdir } from 'node:fs/promises';
|
|
7
|
+
import { resolvePrismaClientImportForServerComponent, resolveServerLightPrismaMigrateDeployArgs, resolveServerLightPrismaSchemaArgs } from '../server/flavor_scripts.mjs';
|
|
6
8
|
|
|
7
9
|
function looksLikeMissingTableError(msg) {
|
|
8
10
|
const s = String(msg ?? '').toLowerCase();
|
|
9
11
|
return s.includes('does not exist') || s.includes('no such table');
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
function looksLikeAlreadyExistsError(msg) {
|
|
15
|
+
const s = String(msg ?? '').toLowerCase();
|
|
16
|
+
return s.includes('already exists') || s.includes('duplicate') || s.includes('constraint failed');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function looksLikeMissingGeneratedSqliteClientError(err) {
|
|
20
|
+
const code = err && typeof err === 'object' ? err.code : '';
|
|
21
|
+
if (code !== 'ERR_MODULE_NOT_FOUND') return false;
|
|
22
|
+
const msg = err instanceof Error ? err.message : String(err ?? '');
|
|
23
|
+
return msg.includes('/generated/sqlite-client/') || msg.includes('\\generated\\sqlite-client\\');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function findSqliteBaselineMigrationDir({ serverDir }) {
|
|
27
|
+
try {
|
|
28
|
+
// Unified monorepo server-light migrations live under prisma/sqlite/migrations.
|
|
29
|
+
// For legacy schema.sqlite.prisma setups, migrations use the default prisma/migrations folder.
|
|
30
|
+
const migrationsDir = existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
|
|
31
|
+
? join(serverDir, 'prisma', 'sqlite', 'migrations')
|
|
32
|
+
: join(serverDir, 'prisma', 'migrations');
|
|
33
|
+
const { readdir } = await import('node:fs/promises');
|
|
34
|
+
const entries = await readdir(migrationsDir, { withFileTypes: true });
|
|
35
|
+
const dirs = entries
|
|
36
|
+
.filter((e) => e.isDirectory())
|
|
37
|
+
.map((e) => e.name)
|
|
38
|
+
.sort();
|
|
39
|
+
return dirs[0] || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function probeAccountCount({ serverComponentName, serverDir, env }) {
|
|
46
|
+
const clientImport = resolvePrismaClientImportForServerComponent({ serverComponentName, serverDir });
|
|
13
47
|
const probe = `
|
|
14
48
|
let db;
|
|
15
49
|
try {
|
|
16
|
-
const { PrismaClient } = await import(
|
|
50
|
+
const { PrismaClient } = await import(${JSON.stringify(clientImport)});
|
|
17
51
|
db = new PrismaClient();
|
|
18
52
|
const accountCount = await db.account.count();
|
|
19
53
|
console.log(JSON.stringify({ accountCount }));
|
|
@@ -90,27 +124,93 @@ export function resolveAuthSeedFromEnv(env) {
|
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
export async function ensureServerLightSchemaReady({ serverDir, env }) {
|
|
93
|
-
await ensureDepsInstalled(serverDir, 'happy-server-light');
|
|
127
|
+
await ensureDepsInstalled(serverDir, 'happy-server-light', { env });
|
|
128
|
+
|
|
129
|
+
const dataDir = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').toString().trim();
|
|
130
|
+
const filesDir = (env?.HAPPY_SERVER_LIGHT_FILES_DIR ?? '').toString().trim() || (dataDir ? join(dataDir, 'files') : '');
|
|
131
|
+
if (dataDir) {
|
|
132
|
+
try {
|
|
133
|
+
await mkdir(dataDir, { recursive: true });
|
|
134
|
+
} catch {
|
|
135
|
+
// best-effort
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (filesDir) {
|
|
139
|
+
try {
|
|
140
|
+
await mkdir(filesDir, { recursive: true });
|
|
141
|
+
} catch {
|
|
142
|
+
// best-effort
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const probe = async () => await probeAccountCount({ serverComponentName: 'happy-server-light', serverDir, env });
|
|
147
|
+
const schemaArgs = resolveServerLightPrismaSchemaArgs({ serverDir });
|
|
94
148
|
|
|
149
|
+
const isUnified = schemaArgs.length > 0;
|
|
150
|
+
|
|
151
|
+
// Unified server-light (monorepo): ensure deterministic migrations are applied (idempotent).
|
|
152
|
+
// Legacy server-light (single schema.prisma with db push): do NOT run `prisma migrate deploy`,
|
|
153
|
+
// because it commonly fails with P3005 when the DB was created by `prisma db push` and no migrations exist.
|
|
154
|
+
if (isUnified) {
|
|
155
|
+
try {
|
|
156
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
159
|
+
// If the SQLite DB was created before migrations existed (historical db push era),
|
|
160
|
+
// `migrate deploy` can fail because tables already exist. Best-effort: baseline-resolve
|
|
161
|
+
// the first migration, then retry deploy.
|
|
162
|
+
if (looksLikeAlreadyExistsError(msg)) {
|
|
163
|
+
const baseline = await findSqliteBaselineMigrationDir({ serverDir });
|
|
164
|
+
if (baseline) {
|
|
165
|
+
await pmExecBin({
|
|
166
|
+
dir: serverDir,
|
|
167
|
+
bin: 'prisma',
|
|
168
|
+
args: ['migrate', 'resolve', ...schemaArgs, '--applied', baseline],
|
|
169
|
+
env,
|
|
170
|
+
}).catch(() => {});
|
|
171
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
|
|
172
|
+
} else {
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
throw e;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2) Probe account count (used for auth seeding heuristics).
|
|
95
182
|
try {
|
|
96
|
-
const accountCount = await
|
|
97
|
-
return { ok: true,
|
|
183
|
+
const accountCount = await probe();
|
|
184
|
+
return { ok: true, migrated: isUnified, accountCount };
|
|
98
185
|
} catch (e) {
|
|
186
|
+
if (looksLikeMissingGeneratedSqliteClientError(e)) {
|
|
187
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['generate', ...schemaArgs], env });
|
|
188
|
+
const accountCount = await probe();
|
|
189
|
+
return { ok: true, migrated: isUnified, accountCount };
|
|
190
|
+
}
|
|
99
191
|
const msg = e instanceof Error ? e.message : String(e);
|
|
100
|
-
if (
|
|
101
|
-
|
|
192
|
+
if (looksLikeMissingTableError(msg)) {
|
|
193
|
+
if (isUnified) {
|
|
194
|
+
// Tables still missing after migrate deploy; fail closed with a clear error.
|
|
195
|
+
throw new Error(`[server-light] sqlite schema not ready after prisma migrate deploy (missing tables).`);
|
|
196
|
+
}
|
|
197
|
+
// Legacy server-light: schema is typically applied via `prisma db push` in the component's dev/start scripts.
|
|
198
|
+
// Best-effort: don't fail the whole stack startup just because we can't probe here.
|
|
199
|
+
return { ok: true, migrated: false, accountCount: 0 };
|
|
200
|
+
}
|
|
201
|
+
if (!isUnified) {
|
|
202
|
+
// Legacy server-light: probing is best-effort (don't make stack dev fail closed here).
|
|
203
|
+
return { ok: true, migrated: false, accountCount: 0 };
|
|
102
204
|
}
|
|
103
|
-
|
|
104
|
-
const accountCount = await probeAccountCount({ serverDir, env });
|
|
105
|
-
return { ok: true, pushed: true, accountCount };
|
|
205
|
+
throw e;
|
|
106
206
|
}
|
|
107
207
|
}
|
|
108
208
|
|
|
109
209
|
export async function ensureHappyServerSchemaReady({ serverDir, env }) {
|
|
110
|
-
await ensureDepsInstalled(serverDir, 'happy-server');
|
|
210
|
+
await ensureDepsInstalled(serverDir, 'happy-server', { env });
|
|
111
211
|
|
|
112
212
|
try {
|
|
113
|
-
const accountCount = await probeAccountCount({ serverDir, env });
|
|
213
|
+
const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
|
|
114
214
|
return { ok: true, migrated: false, accountCount };
|
|
115
215
|
} catch (e) {
|
|
116
216
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -119,7 +219,7 @@ export async function ensureHappyServerSchemaReady({ serverDir, env }) {
|
|
|
119
219
|
}
|
|
120
220
|
// If tables are missing, try migrations (safe for postgres). Then re-probe.
|
|
121
221
|
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
|
|
122
|
-
const accountCount = await probeAccountCount({ serverDir, env });
|
|
222
|
+
const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
|
|
123
223
|
return { ok: true, migrated: true, accountCount };
|
|
124
224
|
}
|
|
125
225
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
import { ensureServerLightSchemaReady } from './startup.mjs';
|
|
9
|
+
|
|
10
|
+
async function writeJson(path, obj) {
|
|
11
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('ensureServerLightSchemaReady creates stack sqlite data dirs before probing', async (t) => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-dirs-'));
|
|
16
|
+
t.after(async () => {
|
|
17
|
+
await rm(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const serverDir = join(root, 'server');
|
|
21
|
+
await mkdir(serverDir, { recursive: true });
|
|
22
|
+
await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
|
|
23
|
+
await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
24
|
+
await mkdir(join(serverDir, 'node_modules'), { recursive: true });
|
|
25
|
+
await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
|
|
26
|
+
|
|
27
|
+
await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
|
|
28
|
+
await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
29
|
+
|
|
30
|
+
// Provide the generated client so we don't need to run prisma generate in this test.
|
|
31
|
+
await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(serverDir, 'generated', 'sqlite-client', 'index.js'),
|
|
34
|
+
['export class PrismaClient {', ' constructor() { this.account = { count: async () => 0 }; }', ' async $disconnect() {}', '}'].join('\n') +
|
|
35
|
+
'\n',
|
|
36
|
+
'utf-8'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Minimal stub `yarn` so commandExists('yarn') succeeds.
|
|
40
|
+
const binDir = join(root, 'bin');
|
|
41
|
+
await mkdir(binDir, { recursive: true });
|
|
42
|
+
const yarnPath = join(binDir, 'yarn');
|
|
43
|
+
await writeFile(yarnPath, ['#!/usr/bin/env node', "console.log('1.22.22');"].join('\n') + '\n', 'utf-8');
|
|
44
|
+
await chmod(yarnPath, 0o755);
|
|
45
|
+
|
|
46
|
+
const dataDir = join(root, 'data');
|
|
47
|
+
const filesDir = join(dataDir, 'files');
|
|
48
|
+
const env = {
|
|
49
|
+
...process.env,
|
|
50
|
+
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
|
51
|
+
HAPPY_SERVER_LIGHT_DATA_DIR: dataDir,
|
|
52
|
+
HAPPY_SERVER_LIGHT_FILES_DIR: filesDir,
|
|
53
|
+
DATABASE_URL: `file:${join(dataDir, 'happy-server-light.sqlite')}`,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
assert.equal(existsSync(dataDir), false);
|
|
57
|
+
assert.equal(existsSync(filesDir), false);
|
|
58
|
+
|
|
59
|
+
await ensureServerLightSchemaReady({ serverDir, env });
|
|
60
|
+
|
|
61
|
+
assert.equal(existsSync(dataDir), true);
|
|
62
|
+
assert.equal(existsSync(filesDir), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile, chmod } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { ensureServerLightSchemaReady } from './startup.mjs';
|
|
8
|
+
|
|
9
|
+
async function writeJson(path, obj) {
|
|
10
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('ensureServerLightSchemaReady runs prisma generate when unified sqlite client is missing', async (t) => {
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-generate-'));
|
|
15
|
+
t.after(async () => {
|
|
16
|
+
await rm(root, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const serverDir = join(root, 'server');
|
|
20
|
+
await mkdir(serverDir, { recursive: true });
|
|
21
|
+
await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
|
|
22
|
+
await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
23
|
+
|
|
24
|
+
// Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
|
|
25
|
+
await mkdir(join(serverDir, 'node_modules'), { recursive: true });
|
|
26
|
+
await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
|
|
27
|
+
|
|
28
|
+
// Unified light detection + expected schema path.
|
|
29
|
+
await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
|
|
30
|
+
await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
31
|
+
|
|
32
|
+
// generated/sqlite-client exists, but the entrypoint is missing (this triggers ERR_MODULE_NOT_FOUND).
|
|
33
|
+
await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
|
|
34
|
+
|
|
35
|
+
// Provide a stub `yarn` in PATH so pmExecBin("prisma", ...) succeeds without real dependencies.
|
|
36
|
+
const binDir = join(root, 'bin');
|
|
37
|
+
await mkdir(binDir, { recursive: true });
|
|
38
|
+
const yarnPath = join(binDir, 'yarn');
|
|
39
|
+
await writeFile(
|
|
40
|
+
yarnPath,
|
|
41
|
+
[
|
|
42
|
+
'#!/usr/bin/env node',
|
|
43
|
+
"const { writeFileSync } = require('node:fs');",
|
|
44
|
+
"const { join } = require('node:path');",
|
|
45
|
+
'const cwd = process.cwd();',
|
|
46
|
+
'const out = join(cwd, "generated", "sqlite-client", "index.js");',
|
|
47
|
+
'const text = [',
|
|
48
|
+
" 'export class PrismaClient {',",
|
|
49
|
+
" ' constructor() { this.account = { count: async () => 0 }; }',",
|
|
50
|
+
" ' async $disconnect() {}',",
|
|
51
|
+
" '}',",
|
|
52
|
+
"].join('\\n') + '\\n';",
|
|
53
|
+
'writeFileSync(out, text, "utf-8");',
|
|
54
|
+
'process.exit(0);',
|
|
55
|
+
].join('\n') + '\n',
|
|
56
|
+
'utf-8'
|
|
57
|
+
);
|
|
58
|
+
await chmod(yarnPath, 0o755);
|
|
59
|
+
|
|
60
|
+
const oldPath = process.env.PATH;
|
|
61
|
+
try {
|
|
62
|
+
process.env.PATH = `${binDir}:${oldPath ?? ''}`;
|
|
63
|
+
const res = await ensureServerLightSchemaReady({ serverDir, env: process.env });
|
|
64
|
+
assert.equal(res.ok, true);
|
|
65
|
+
assert.equal(res.migrated, true);
|
|
66
|
+
assert.equal(res.accountCount, 0);
|
|
67
|
+
} finally {
|
|
68
|
+
process.env.PATH = oldPath;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
import { ensureServerLightSchemaReady } from './startup.mjs';
|
|
9
|
+
|
|
10
|
+
async function writeJson(path, obj) {
|
|
11
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('ensureServerLightSchemaReady does not run prisma migrate deploy for legacy happy-server-light checkouts', async (t) => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-legacy-'));
|
|
16
|
+
t.after(async () => {
|
|
17
|
+
await rm(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const serverDir = join(root, 'server');
|
|
21
|
+
await mkdir(serverDir, { recursive: true });
|
|
22
|
+
await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0', type: 'module' });
|
|
23
|
+
await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
24
|
+
|
|
25
|
+
// Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
|
|
26
|
+
await mkdir(join(serverDir, 'node_modules'), { recursive: true });
|
|
27
|
+
await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
|
|
28
|
+
|
|
29
|
+
// Legacy checkout: no prisma/sqlite/schema.prisma and no prisma/schema.sqlite.prisma.
|
|
30
|
+
// Provide a minimal node_modules @prisma/client so probeAccountCount can succeed.
|
|
31
|
+
await mkdir(join(serverDir, 'node_modules', '@prisma', 'client'), { recursive: true });
|
|
32
|
+
await writeJson(join(serverDir, 'node_modules', '@prisma', 'client', 'package.json'), {
|
|
33
|
+
name: '@prisma/client',
|
|
34
|
+
type: 'module',
|
|
35
|
+
main: './index.js',
|
|
36
|
+
});
|
|
37
|
+
await writeFile(
|
|
38
|
+
join(serverDir, 'node_modules', '@prisma', 'client', 'index.js'),
|
|
39
|
+
[
|
|
40
|
+
'export class PrismaClient {',
|
|
41
|
+
' constructor() { this.account = { count: async () => 0 }; }',
|
|
42
|
+
' async $disconnect() {}',
|
|
43
|
+
'}',
|
|
44
|
+
].join('\n') + '\n',
|
|
45
|
+
'utf-8'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const marker = join(root, 'called-prisma.txt');
|
|
49
|
+
|
|
50
|
+
// Provide a stub `yarn` so ensureYarnReady + pmExecBin are controllable.
|
|
51
|
+
const binDir = join(root, 'bin');
|
|
52
|
+
await mkdir(binDir, { recursive: true });
|
|
53
|
+
const yarnPath = join(binDir, 'yarn');
|
|
54
|
+
await writeFile(
|
|
55
|
+
yarnPath,
|
|
56
|
+
[
|
|
57
|
+
'#!/usr/bin/env node',
|
|
58
|
+
"const fs = require('node:fs');",
|
|
59
|
+
"const path = require('node:path');",
|
|
60
|
+
"const args = process.argv.slice(2);",
|
|
61
|
+
// ensureYarnReady calls: yarn --version
|
|
62
|
+
"if (args.includes('--version')) { console.log('1.22.22'); process.exit(0); }",
|
|
63
|
+
// pmExecBin calls: yarn run prisma ...
|
|
64
|
+
"if (args[0] === 'run' && args[1] === 'prisma') {",
|
|
65
|
+
` fs.writeFileSync(${JSON.stringify(marker)}, args.join(' ') + '\\n', 'utf-8');`,
|
|
66
|
+
' process.exit(0);',
|
|
67
|
+
'}',
|
|
68
|
+
"console.log('ok');",
|
|
69
|
+
'process.exit(0);',
|
|
70
|
+
].join('\n') + '\n',
|
|
71
|
+
'utf-8'
|
|
72
|
+
);
|
|
73
|
+
await chmod(yarnPath, 0o755);
|
|
74
|
+
|
|
75
|
+
const env = {
|
|
76
|
+
...process.env,
|
|
77
|
+
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
|
78
|
+
DATABASE_URL: `file:${join(root, 'happy-server-light.sqlite')}`,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const res = await ensureServerLightSchemaReady({ serverDir, env });
|
|
82
|
+
assert.equal(res.ok, true);
|
|
83
|
+
assert.equal(res.migrated, false);
|
|
84
|
+
assert.equal(res.accountCount, 0);
|
|
85
|
+
|
|
86
|
+
assert.equal(existsSync(marker), false, `expected no prisma migrate deploy call, but saw: ${marker}`);
|
|
87
|
+
});
|
|
88
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailscale IP detection utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to detect the local Tailscale IPv4 address for port forwarding.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runCaptureResult } from '../proc/proc.mjs';
|
|
8
|
+
import { resolveCommandPath } from '../proc/commands.mjs';
|
|
9
|
+
import { access, constants } from 'node:fs/promises';
|
|
10
|
+
|
|
11
|
+
const TAILSCALE_TIMEOUT_MS = 3000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a path is executable.
|
|
15
|
+
*/
|
|
16
|
+
async function isExecutable(path) {
|
|
17
|
+
try {
|
|
18
|
+
await access(path, constants.X_OK);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tailscale env: strip XPC_SERVICE_NAME which can cause hangs in LaunchAgent contexts.
|
|
27
|
+
*/
|
|
28
|
+
function tailscaleEnv() {
|
|
29
|
+
const env = { ...process.env };
|
|
30
|
+
delete env.XPC_SERVICE_NAME;
|
|
31
|
+
return env;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the tailscale CLI path.
|
|
36
|
+
*
|
|
37
|
+
* Priority:
|
|
38
|
+
* 1. HAPPY_LOCAL_TAILSCALE_BIN env override
|
|
39
|
+
* 2. PATH lookup
|
|
40
|
+
* 3. macOS app bundle paths
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveTailscaleCmd() {
|
|
43
|
+
// Explicit override
|
|
44
|
+
if (process.env.HAPPY_LOCAL_TAILSCALE_BIN?.trim()) {
|
|
45
|
+
return process.env.HAPPY_LOCAL_TAILSCALE_BIN.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Try PATH first
|
|
49
|
+
try {
|
|
50
|
+
const found = await resolveCommandPath('tailscale', { env: tailscaleEnv(), timeoutMs: TAILSCALE_TIMEOUT_MS });
|
|
51
|
+
if (found) return found;
|
|
52
|
+
} catch {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// macOS app bundle paths
|
|
57
|
+
const appCliPath = '/Applications/Tailscale.app/Contents/MacOS/tailscale';
|
|
58
|
+
if (await isExecutable(appCliPath)) return appCliPath;
|
|
59
|
+
|
|
60
|
+
const appPath = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
61
|
+
if (await isExecutable(appPath)) return appPath;
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the local Tailscale IPv4 address.
|
|
68
|
+
*
|
|
69
|
+
* @returns {Promise<string | null>} The Tailscale IPv4 address, or null if unavailable.
|
|
70
|
+
*/
|
|
71
|
+
export async function getTailscaleIpv4() {
|
|
72
|
+
const cmd = await resolveTailscaleCmd();
|
|
73
|
+
if (!cmd) return null;
|
|
74
|
+
|
|
75
|
+
const result = await runCaptureResult(cmd, ['ip', '-4'], {
|
|
76
|
+
env: tailscaleEnv(),
|
|
77
|
+
timeoutMs: TAILSCALE_TIMEOUT_MS,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!result.ok) return null;
|
|
81
|
+
|
|
82
|
+
const ip = result.out.trim().split('\n')[0]?.trim();
|
|
83
|
+
// Validate IPv4 format (basic check)
|
|
84
|
+
if (!ip || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) return null;
|
|
85
|
+
|
|
86
|
+
return ip;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if Tailscale is available and connected.
|
|
91
|
+
*
|
|
92
|
+
* @returns {Promise<boolean>}
|
|
93
|
+
*/
|
|
94
|
+
export async function isTailscaleAvailable() {
|
|
95
|
+
const ip = await getTailscaleIpv4();
|
|
96
|
+
return Boolean(ip);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get Tailscale status information.
|
|
101
|
+
*
|
|
102
|
+
* @returns {Promise<{ available: boolean, ip: string | null, error: string | null }>}
|
|
103
|
+
*/
|
|
104
|
+
export async function getTailscaleStatus() {
|
|
105
|
+
const cmd = await resolveTailscaleCmd();
|
|
106
|
+
if (!cmd) {
|
|
107
|
+
return { available: false, ip: null, error: 'tailscale CLI not found' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ip = await getTailscaleIpv4();
|
|
111
|
+
if (!ip) {
|
|
112
|
+
return { available: false, ip: null, error: 'tailscale not connected or no IPv4 address' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { available: true, ip, error: null };
|
|
116
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function supportsAnsi() {
|
|
2
|
+
if (!process.stdout.isTTY) return false;
|
|
3
|
+
if (process.env.NO_COLOR) return false;
|
|
4
|
+
if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function wrap(code, s) {
|
|
9
|
+
return supportsAnsi() ? `\x1b[${code}m${s}\x1b[0m` : String(s);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ansiEnabled() {
|
|
13
|
+
return supportsAnsi();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function bold(s) {
|
|
17
|
+
return wrap('1', s);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function dim(s) {
|
|
21
|
+
return wrap('2', s);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function red(s) {
|
|
25
|
+
return wrap('31', s);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function green(s) {
|
|
29
|
+
return wrap('32', s);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function yellow(s) {
|
|
33
|
+
return wrap('33', s);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function cyan(s) {
|
|
37
|
+
return wrap('36', s);
|
|
38
|
+
}
|
|
39
|
+
|
package/scripts/where.mjs
CHANGED
|
@@ -26,8 +26,8 @@ async function main() {
|
|
|
26
26
|
if (wantsHelp(argv, { flags }) || argv.includes('help')) {
|
|
27
27
|
printResult({
|
|
28
28
|
json,
|
|
29
|
-
data: { flags: ['--json'], commands: ['where'
|
|
30
|
-
text: ['[where] usage:', ' happys where [--json]'
|
|
29
|
+
data: { flags: ['--json'], commands: ['where'] },
|
|
30
|
+
text: ['[where] usage:', ' happys where [--json]'].join('\n'),
|
|
31
31
|
});
|
|
32
32
|
return;
|
|
33
33
|
}
|