@indigoai-us/hq-cli 5.8.6 → 5.9.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/CHANGELOG.md +30 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +119 -0
- package/dist/commands/secrets.d.ts +3 -9
- package/dist/commands/secrets.js +5 -56
- package/dist/index.js +5 -2
- package/dist/run/discover-schemas.d.ts +12 -0
- package/dist/run/discover-schemas.js +64 -0
- package/dist/run/hq-plugin.d.ts +26 -0
- package/dist/run/hq-plugin.js +144 -0
- package/dist/utils/vault-api.d.ts +10 -0
- package/dist/utils/vault-api.js +58 -0
- package/package.json +7 -3
- package/src/commands/run.env-local.test.ts +84 -0
- package/src/commands/run.ts +137 -0
- package/src/commands/secrets.ts +4 -88
- package/src/index.ts +4 -0
- package/src/run/__fixtures__/discover-schemas/example.env.schema +4 -0
- package/src/run/discover-schemas.test.ts +153 -0
- package/src/run/discover-schemas.ts +79 -0
- package/src/run/hq-plugin.test.ts +125 -0
- package/src/run/hq-plugin.ts +174 -0
- package/src/run/varlock-shape.test.ts +57 -0
- package/src/utils/vault-api.ts +80 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { internal } from 'varlock';
|
|
6
|
+
import { discoverSchemas } from '../run/discover-schemas.js';
|
|
7
|
+
import { installHqPlugin, prewarmHqSecrets, type PluginState } from '../run/hq-plugin.js';
|
|
8
|
+
|
|
9
|
+
vi.mock('../utils/secrets-cache.js', () => {
|
|
10
|
+
const store = new Map<string, string>();
|
|
11
|
+
return {
|
|
12
|
+
readCache: (uid: string, name: string): string | null =>
|
|
13
|
+
store.get(`${uid}\0${name}`) ?? null,
|
|
14
|
+
writeCache: (uid: string, name: string, value: string): void => {
|
|
15
|
+
store.set(`${uid}\0${name}`, value);
|
|
16
|
+
},
|
|
17
|
+
removeCacheEntry: (): void => {},
|
|
18
|
+
clearAllCache: (): { removed: number } => ({ removed: 0 }),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let dir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
dir = mkdtempSync(join(tmpdir(), 'hq-run-envlocal-'));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('.env.local overrides hq()-resolved value', async () => {
|
|
33
|
+
// Blank line after @hqCompany required: varlock only treats it as a root (file-level)
|
|
34
|
+
// decorator when separated from the first var by a blank line.
|
|
35
|
+
writeFileSync(join(dir, '.env.schema'), '# @hqCompany("indigo")\n\n# @required\nKEY=hq()\n');
|
|
36
|
+
writeFileSync(join(dir, '.env.local'), 'KEY=local-value\n');
|
|
37
|
+
|
|
38
|
+
const result = discoverSchemas(dir);
|
|
39
|
+
expect(result.schemaPaths).toEqual([join(dir, '.env.schema')]);
|
|
40
|
+
expect(result.envLocalPaths).toEqual([join(dir, '.env.local')]);
|
|
41
|
+
|
|
42
|
+
const paths = [...result.schemaPaths, ...result.envLocalPaths];
|
|
43
|
+
const opts = {
|
|
44
|
+
companyOverride: undefined,
|
|
45
|
+
resolveCompanyUid: async () => 'fake-uid',
|
|
46
|
+
// fetchBatch returns "vault-value" — if .env.local is correctly wired,
|
|
47
|
+
// graph resolution should still surface "local-value" because the local
|
|
48
|
+
// file wins precedence.
|
|
49
|
+
fetchBatch: async () => ({ secrets: [{ name: 'KEY', value: 'vault-value' }], errors: [] }),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let state!: PluginState;
|
|
53
|
+
const graph = await internal.loadEnvGraph({
|
|
54
|
+
entryFilePaths: paths,
|
|
55
|
+
afterInit: (g) => { state = installHqPlugin(g, opts); },
|
|
56
|
+
});
|
|
57
|
+
await prewarmHqSecrets(graph, opts, state);
|
|
58
|
+
await graph.resolveEnvValues();
|
|
59
|
+
expect(graph.getResolvedEnvObject().KEY).toBe('local-value');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('without .env.local, vault value is returned', async () => {
|
|
63
|
+
writeFileSync(join(dir, '.env.schema'), '# @hqCompany("indigo")\n\n# @required\nKEY=hq()\n');
|
|
64
|
+
|
|
65
|
+
const result = discoverSchemas(dir);
|
|
66
|
+
expect(result.schemaPaths).toEqual([join(dir, '.env.schema')]);
|
|
67
|
+
expect(result.envLocalPaths).toEqual([]);
|
|
68
|
+
|
|
69
|
+
const paths = [...result.schemaPaths, ...result.envLocalPaths];
|
|
70
|
+
const opts = {
|
|
71
|
+
companyOverride: undefined,
|
|
72
|
+
resolveCompanyUid: async () => 'fake-uid',
|
|
73
|
+
fetchBatch: async () => ({ secrets: [{ name: 'KEY', value: 'vault-value' }], errors: [] }),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let state!: PluginState;
|
|
77
|
+
const graph = await internal.loadEnvGraph({
|
|
78
|
+
entryFilePaths: paths,
|
|
79
|
+
afterInit: (g) => { state = installHqPlugin(g, opts); },
|
|
80
|
+
});
|
|
81
|
+
await prewarmHqSecrets(graph, opts, state);
|
|
82
|
+
await graph.resolveEnvValues();
|
|
83
|
+
expect(graph.getResolvedEnvObject().KEY).toBe('vault-value');
|
|
84
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import { internal } from 'varlock';
|
|
6
|
+
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
7
|
+
import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
|
|
8
|
+
import { discoverSchemas } from '../run/discover-schemas.js';
|
|
9
|
+
import { installHqPlugin, prewarmHqSecrets, type PluginState, type InstallHqPluginOpts } from '../run/hq-plugin.js';
|
|
10
|
+
|
|
11
|
+
export function registerRunCommand(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command('run')
|
|
14
|
+
.description('Load secrets from .env.schema and run a command with them injected')
|
|
15
|
+
.option('--company <slug>', 'Company slug (overrides @hqCompany in schema)')
|
|
16
|
+
.option('--schema <path>', 'Explicit schema path (skips walk-up discovery)')
|
|
17
|
+
.option('--check', 'Resolve schema and validate vars without executing the command')
|
|
18
|
+
.allowUnknownOption(true)
|
|
19
|
+
.action(async (opts: { company?: string; schema?: string; check?: boolean }) => {
|
|
20
|
+
try {
|
|
21
|
+
const dashIndex = process.argv.indexOf('--');
|
|
22
|
+
const childArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : [];
|
|
23
|
+
|
|
24
|
+
if (!opts.check && childArgs.length === 0) {
|
|
25
|
+
throw new Error('no command specified. Usage: hq run [options] -- <command> [args...]');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let schemaPaths: string[];
|
|
29
|
+
let envLocalPaths: string[];
|
|
30
|
+
let schemaCompanySlug: string | null;
|
|
31
|
+
|
|
32
|
+
if (opts.schema) {
|
|
33
|
+
const schemaAbs = path.resolve(opts.schema);
|
|
34
|
+
schemaPaths = [schemaAbs];
|
|
35
|
+
const localPath = path.join(path.dirname(schemaAbs), '.env.local');
|
|
36
|
+
envLocalPaths = fs.existsSync(localPath) ? [localPath] : [];
|
|
37
|
+
const content = fs.readFileSync(schemaAbs, 'utf8');
|
|
38
|
+
const m = /^# @hqCompany\("([^"]+)"\)/m.exec(content);
|
|
39
|
+
schemaCompanySlug = m ? m[1] : null;
|
|
40
|
+
} else {
|
|
41
|
+
const discovered = discoverSchemas(process.cwd());
|
|
42
|
+
if (discovered.conflict) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`conflicting @hqCompany slugs: "${discovered.conflict.slugs[0]}" in ${discovered.conflict.paths[0]} vs "${discovered.conflict.slugs[1]}" in ${discovered.conflict.paths[1]}. Use --company <slug> to override.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (discovered.schemaPaths.length === 0) {
|
|
48
|
+
throw new Error('no .env.schema found. Create one or use --schema <path>.');
|
|
49
|
+
}
|
|
50
|
+
schemaPaths = discovered.schemaPaths;
|
|
51
|
+
envLocalPaths = discovered.envLocalPaths;
|
|
52
|
+
schemaCompanySlug = discovered.companySlug;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const slug = opts.company ?? schemaCompanySlug;
|
|
56
|
+
if (!slug) {
|
|
57
|
+
throw new Error('company slug not set. Add # @hqCompany("slug") to your .env.schema or pass --company <slug>.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const token = await ensureCognitoToken();
|
|
61
|
+
const uid = await getCompanyUid(token, slug);
|
|
62
|
+
|
|
63
|
+
const fetchBatch: InstallHqPluginOpts['fetchBatch'] = async (companyUid, names) => {
|
|
64
|
+
const res = await vaultApiFetch({
|
|
65
|
+
token,
|
|
66
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/load`,
|
|
67
|
+
method: 'POST',
|
|
68
|
+
body: { names },
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const body = await res.json().catch(() => ({})) as Record<string, string>;
|
|
72
|
+
throw new Error(`Failed to batch-load secrets: ${body.error ?? res.statusText}`);
|
|
73
|
+
}
|
|
74
|
+
return res.json() as Promise<{
|
|
75
|
+
secrets: Array<{ name: string; value: string }>;
|
|
76
|
+
errors: Array<{ name: string; code: string; message?: string }>;
|
|
77
|
+
}>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const pluginOpts: InstallHqPluginOpts = {
|
|
81
|
+
companyOverride: opts.company,
|
|
82
|
+
resolveCompanyUid: async () => uid,
|
|
83
|
+
fetchBatch,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// LAST entry = highest precedence; .env.local files trail .env.schema files so any .env.local beats any schema regardless of depth.
|
|
87
|
+
const paths = [...schemaPaths, ...envLocalPaths];
|
|
88
|
+
|
|
89
|
+
let state!: PluginState;
|
|
90
|
+
const graph = await internal.loadEnvGraph({
|
|
91
|
+
entryFilePaths: paths,
|
|
92
|
+
afterInit: async (g) => { state = installHqPlugin(g, pluginOpts); },
|
|
93
|
+
});
|
|
94
|
+
await prewarmHqSecrets(graph, pluginOpts, state);
|
|
95
|
+
await graph.resolveEnvValues();
|
|
96
|
+
|
|
97
|
+
const schemaErrors = Object.entries(graph.configSchema as Record<string, any>)
|
|
98
|
+
.filter(([, item]) => (item.errors as unknown[])?.length > 0);
|
|
99
|
+
if (schemaErrors.length > 0) {
|
|
100
|
+
const msgs = schemaErrors.flatMap(([k, item]) =>
|
|
101
|
+
(item.errors as Array<{ message?: string }>).map((e) => ` ${k}: ${e.message ?? String(e)}`),
|
|
102
|
+
);
|
|
103
|
+
process.stderr.write(`Error: failed to resolve env vars:\n${msgs.join('\n')}\n`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const resolvedEnv = graph.getResolvedEnvObject() as Record<string, string>;
|
|
108
|
+
const varCount = Object.keys(resolvedEnv).length;
|
|
109
|
+
process.stderr.write(`Loaded ${varCount} env vars from .env.schema (company: ${slug})\n`);
|
|
110
|
+
|
|
111
|
+
if (opts.check) {
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const [childCmd, ...restArgs] = childArgs;
|
|
116
|
+
const child = spawn(childCmd, restArgs, {
|
|
117
|
+
stdio: 'inherit',
|
|
118
|
+
env: { ...process.env, ...resolvedEnv },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
child.on('error', (err) => {
|
|
122
|
+
process.stderr.write(`Error: failed to start command '${childCmd}': ${err.message}\n`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
child.on('close', (code, signal) => {
|
|
127
|
+
if (signal) {
|
|
128
|
+
process.kill(process.pid, signal);
|
|
129
|
+
}
|
|
130
|
+
process.exit(code ?? 1);
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
package/src/commands/secrets.ts
CHANGED
|
@@ -2,10 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import * as readline from "node:readline";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import {
|
|
6
|
-
ensureCognitoToken,
|
|
7
|
-
DEFAULT_VAULT_API_URL,
|
|
8
|
-
} from "../utils/cognito-session.js";
|
|
5
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
9
6
|
import {
|
|
10
7
|
readCache,
|
|
11
8
|
writeCache,
|
|
@@ -13,14 +10,9 @@ import {
|
|
|
13
10
|
clearAllCache,
|
|
14
11
|
} from "../utils/secrets-cache.js";
|
|
15
12
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
|
|
16
|
-
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
path: string;
|
|
20
|
-
method?: string;
|
|
21
|
-
body?: Record<string, unknown>;
|
|
22
|
-
query?: Record<string, string>;
|
|
23
|
-
}
|
|
13
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
14
|
+
export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
15
|
+
export { vaultApiFetch, getCompanyUid };
|
|
24
16
|
|
|
25
17
|
function shellSingleQuote(value: string): string {
|
|
26
18
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
@@ -37,82 +29,6 @@ function buildSecretNamePath(companyUid: string, name: string): string {
|
|
|
37
29
|
return `/secrets/${encodeURIComponent(companyUid)}/name/${encodedName}`;
|
|
38
30
|
}
|
|
39
31
|
|
|
40
|
-
export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
|
|
41
|
-
const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
|
|
42
|
-
if (opts.query) {
|
|
43
|
-
for (const [k, v] of Object.entries(opts.query)) {
|
|
44
|
-
url.searchParams.set(k, v);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return fetch(url.toString(), {
|
|
48
|
-
method: opts.method ?? "GET",
|
|
49
|
-
headers: {
|
|
50
|
-
Authorization: `Bearer ${opts.token}`,
|
|
51
|
-
"Content-Type": "application/json",
|
|
52
|
-
},
|
|
53
|
-
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function resolveCompanyUid(
|
|
58
|
-
token: string,
|
|
59
|
-
slug: string,
|
|
60
|
-
): Promise<string> {
|
|
61
|
-
const res = await vaultApiFetch({
|
|
62
|
-
token,
|
|
63
|
-
path: `/entity/by-slug/company/${encodeURIComponent(slug)}`,
|
|
64
|
-
});
|
|
65
|
-
if (!res.ok) {
|
|
66
|
-
const body = await res.json().catch(() => ({}));
|
|
67
|
-
throw new Error(
|
|
68
|
-
`Failed to resolve company slug '${slug}': ${(body as Record<string, string>).error ?? res.statusText}`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
const data = (await res.json()) as { entity: { uid: string } };
|
|
72
|
-
return data.entity.uid;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
interface MembershipEntry {
|
|
76
|
-
companyUid: string;
|
|
77
|
-
role: string;
|
|
78
|
-
status: string;
|
|
79
|
-
membershipKey: string;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function resolveCompanyFromMemberships(
|
|
83
|
-
token: string,
|
|
84
|
-
): Promise<string> {
|
|
85
|
-
const res = await vaultApiFetch({
|
|
86
|
-
token,
|
|
87
|
-
path: "/membership/me",
|
|
88
|
-
});
|
|
89
|
-
if (!res.ok) {
|
|
90
|
-
throw new Error("Failed to fetch memberships — run `hq login` and try again");
|
|
91
|
-
}
|
|
92
|
-
const data = (await res.json()) as { memberships: MembershipEntry[] };
|
|
93
|
-
const active = data.memberships.filter((m) => m.status === "active");
|
|
94
|
-
if (active.length === 0) {
|
|
95
|
-
throw new Error("No active company memberships found. Use --company <slug> to specify.");
|
|
96
|
-
}
|
|
97
|
-
if (active.length === 1) {
|
|
98
|
-
return active[0].companyUid;
|
|
99
|
-
}
|
|
100
|
-
const uids = active.map((m) => m.companyUid).join(", ");
|
|
101
|
-
throw new Error(
|
|
102
|
-
`Multiple companies found (${uids}). Use --company <slug> to specify which one.`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export async function getCompanyUid(
|
|
107
|
-
token: string,
|
|
108
|
-
companySlug: string | undefined,
|
|
109
|
-
): Promise<string> {
|
|
110
|
-
if (companySlug) {
|
|
111
|
-
return resolveCompanyUid(token, companySlug);
|
|
112
|
-
}
|
|
113
|
-
return resolveCompanyFromMemberships(token);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
32
|
function parseDuration(input: string): number | null {
|
|
117
33
|
const match = input.match(/^(\d+)(m|h|d)$/);
|
|
118
34
|
if (!match) return null;
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { registerPackageListCommand } from "./commands/pkg-list.js";
|
|
|
23
23
|
import { registerTeamSyncCommand } from "./commands/team-sync.js";
|
|
24
24
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
25
25
|
import { registerSecretsCommand } from "./commands/secrets.js";
|
|
26
|
+
import { registerRunCommand } from "./commands/run.js";
|
|
26
27
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
27
28
|
import { registerFilesCommand } from "./commands/files.js";
|
|
28
29
|
|
|
@@ -90,6 +91,9 @@ registerAuthCommands(program);
|
|
|
90
91
|
// Secrets management (subcommand group — hq secrets set|get|list|delete|exec|generate-link|cache)
|
|
91
92
|
registerSecretsCommand(program);
|
|
92
93
|
|
|
94
|
+
// Schema-driven dev runner — hq run [options] -- <cmd>
|
|
95
|
+
registerRunCommand(program);
|
|
96
|
+
|
|
93
97
|
// Groups management (subcommand group — hq groups create|delete|add|remove|list|members)
|
|
94
98
|
registerGroupsCommand(program);
|
|
95
99
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { discoverSchemas } from './discover-schemas.js';
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
// Helper: create directory + optional files inside it.
|
|
10
|
+
function mkDir(...segments: string[]): string {
|
|
11
|
+
const dir = path.join(tmpDir, ...segments);
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeFile(dir: string, name: string, content: string): string {
|
|
17
|
+
const p = path.join(dir, name);
|
|
18
|
+
fs.writeFileSync(p, content);
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Every temp tree gets a .git/ at the root so the walk-up never escapes tmpDir.
|
|
23
|
+
function makeRoot(): string {
|
|
24
|
+
const root = mkDir('root');
|
|
25
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SCHEMA_CO_A = '# @hqCompany("co-a")\n\nFOO=hq()\n';
|
|
30
|
+
const SCHEMA_CO_B = '# @hqCompany("co-b")\n\nBAR=hq()\n';
|
|
31
|
+
const SCHEMA_CO_E = '# @hqCompany("co-e")\n\nFOO=hq()\n';
|
|
32
|
+
const SCHEMA_CO_F = '# @hqCompany("co-f")\n\nFOO=hq()\n';
|
|
33
|
+
const SCHEMA_CO_G = '# @hqCompany("co-g")\n\nFOO=hq()\n';
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discover-schemas-test-'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('discoverSchemas', () => {
|
|
44
|
+
// (a) Single schema in cwd — schemaPaths length 1, envLocalPaths length 0, slug matches.
|
|
45
|
+
it('(a) single schema in cwd', () => {
|
|
46
|
+
const root = makeRoot();
|
|
47
|
+
const cwd = mkDir('root', 'cwd');
|
|
48
|
+
writeFile(cwd, '.env.schema', SCHEMA_CO_A);
|
|
49
|
+
|
|
50
|
+
const result = discoverSchemas(cwd);
|
|
51
|
+
|
|
52
|
+
expect(result.schemaPaths).toHaveLength(1);
|
|
53
|
+
expect(result.schemaPaths[0]).toBe(path.join(cwd, '.env.schema'));
|
|
54
|
+
expect(result.envLocalPaths).toHaveLength(0);
|
|
55
|
+
expect(result.companySlug).toBe('co-a');
|
|
56
|
+
expect(result.conflict).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// (b) Parent + child both have schemas with same slug — schemaPaths length 2 (parent first, child last).
|
|
60
|
+
it('(b) parent and child schemas with same slug', () => {
|
|
61
|
+
const root = makeRoot();
|
|
62
|
+
const parent = mkDir('root', 'parent');
|
|
63
|
+
const child = mkDir('root', 'parent', 'child');
|
|
64
|
+
writeFile(parent, '.env.schema', SCHEMA_CO_A);
|
|
65
|
+
writeFile(child, '.env.schema', SCHEMA_CO_A);
|
|
66
|
+
|
|
67
|
+
const result = discoverSchemas(child);
|
|
68
|
+
|
|
69
|
+
expect(result.schemaPaths).toHaveLength(2);
|
|
70
|
+
expect(result.schemaPaths[0]).toBe(path.join(parent, '.env.schema')); // parent first
|
|
71
|
+
expect(result.schemaPaths[1]).toBe(path.join(child, '.env.schema')); // child last (cwd-closest)
|
|
72
|
+
expect(result.companySlug).toBe('co-a');
|
|
73
|
+
expect(result.conflict).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// (c) Parent has slug A, child has slug B — conflict reported with both paths and both slugs.
|
|
77
|
+
it('(c) conflicting slugs', () => {
|
|
78
|
+
const root = makeRoot();
|
|
79
|
+
const parent = mkDir('root', 'parent');
|
|
80
|
+
const child = mkDir('root', 'parent', 'child');
|
|
81
|
+
writeFile(parent, '.env.schema', SCHEMA_CO_A);
|
|
82
|
+
writeFile(child, '.env.schema', SCHEMA_CO_B);
|
|
83
|
+
|
|
84
|
+
const result = discoverSchemas(child);
|
|
85
|
+
|
|
86
|
+
expect(result.schemaPaths).toHaveLength(2);
|
|
87
|
+
expect(result.companySlug).toBeNull();
|
|
88
|
+
expect(result.conflict).not.toBeNull();
|
|
89
|
+
expect(result.conflict!.paths).toContain(path.join(parent, '.env.schema'));
|
|
90
|
+
expect(result.conflict!.paths).toContain(path.join(child, '.env.schema'));
|
|
91
|
+
expect(result.conflict!.slugs).toContain('co-a');
|
|
92
|
+
expect(result.conflict!.slugs).toContain('co-b');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// (d) No schema found from cwd up — empty result.
|
|
96
|
+
it('(d) no schema found', () => {
|
|
97
|
+
const root = makeRoot();
|
|
98
|
+
const cwd = mkDir('root', 'cwd');
|
|
99
|
+
|
|
100
|
+
const result = discoverSchemas(cwd);
|
|
101
|
+
|
|
102
|
+
expect(result.schemaPaths).toHaveLength(0);
|
|
103
|
+
expect(result.envLocalPaths).toHaveLength(0);
|
|
104
|
+
expect(result.companySlug).toBeNull();
|
|
105
|
+
expect(result.conflict).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// (e) Schema at cwd PLUS sibling .env.local — schemaPaths length 1, envLocalPaths length 1.
|
|
109
|
+
it('(e) schema with sibling .env.local', () => {
|
|
110
|
+
const root = makeRoot();
|
|
111
|
+
const cwd = mkDir('root', 'cwd');
|
|
112
|
+
writeFile(cwd, '.env.schema', SCHEMA_CO_E);
|
|
113
|
+
writeFile(cwd, '.env.local', 'FOO=local-override\n');
|
|
114
|
+
|
|
115
|
+
const result = discoverSchemas(cwd);
|
|
116
|
+
|
|
117
|
+
expect(result.schemaPaths).toHaveLength(1);
|
|
118
|
+
expect(result.envLocalPaths).toHaveLength(1);
|
|
119
|
+
expect(result.envLocalPaths[0]).toBe(path.join(cwd, '.env.local'));
|
|
120
|
+
expect(result.companySlug).toBe('co-e');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// (f) Repo-root has .env.schema next to .git/, cwd is grandchild — schemaPaths length 1
|
|
124
|
+
// pointing at repo-root (.git/-containing dir IS included).
|
|
125
|
+
it('(f) schema at .git root, cwd is grandchild', () => {
|
|
126
|
+
const root = makeRoot(); // root has .git/ already
|
|
127
|
+
writeFile(root, '.env.schema', SCHEMA_CO_F);
|
|
128
|
+
const child = mkDir('root', 'child');
|
|
129
|
+
const grandchild = mkDir('root', 'child', 'grandchild');
|
|
130
|
+
|
|
131
|
+
const result = discoverSchemas(grandchild);
|
|
132
|
+
|
|
133
|
+
expect(result.schemaPaths).toHaveLength(1);
|
|
134
|
+
expect(result.schemaPaths[0]).toBe(path.join(root, '.env.schema'));
|
|
135
|
+
expect(result.companySlug).toBe('co-f');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// (g) Parent has .env.local but no schema, child has schema — envLocalPaths length 0
|
|
139
|
+
// (.env.local collected ONLY when next to a .env.schema).
|
|
140
|
+
it('(g) .env.local without schema in parent is ignored', () => {
|
|
141
|
+
const root = makeRoot();
|
|
142
|
+
const parent = mkDir('root', 'parent');
|
|
143
|
+
const child = mkDir('root', 'parent', 'child');
|
|
144
|
+
writeFile(parent, '.env.local', 'PARENT_ONLY=1\n');
|
|
145
|
+
writeFile(child, '.env.schema', SCHEMA_CO_G);
|
|
146
|
+
|
|
147
|
+
const result = discoverSchemas(child);
|
|
148
|
+
|
|
149
|
+
expect(result.schemaPaths).toHaveLength(1);
|
|
150
|
+
expect(result.envLocalPaths).toHaveLength(0);
|
|
151
|
+
expect(result.companySlug).toBe('co-g');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface SchemaConflict {
|
|
5
|
+
paths: [string, string];
|
|
6
|
+
slugs: [string, string];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DiscoverSchemasResult {
|
|
10
|
+
schemaPaths: string[];
|
|
11
|
+
envLocalPaths: string[];
|
|
12
|
+
companySlug: string | null;
|
|
13
|
+
conflict: SchemaConflict | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SLUG_RE = /^# @hqCompany\("([^"]+)"\)/m;
|
|
17
|
+
|
|
18
|
+
function parseSlug(filePath: string): string | null {
|
|
19
|
+
try {
|
|
20
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
21
|
+
const m = SLUG_RE.exec(content);
|
|
22
|
+
return m ? m[1] : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function discoverSchemas(cwd: string): DiscoverSchemasResult {
|
|
29
|
+
const schemaPaths: string[] = [];
|
|
30
|
+
const envLocalPaths: string[] = [];
|
|
31
|
+
|
|
32
|
+
let dir = path.resolve(cwd);
|
|
33
|
+
|
|
34
|
+
while (true) {
|
|
35
|
+
const schemaPath = path.join(dir, '.env.schema');
|
|
36
|
+
if (fs.existsSync(schemaPath)) {
|
|
37
|
+
schemaPaths.push(schemaPath);
|
|
38
|
+
const localPath = path.join(dir, '.env.local');
|
|
39
|
+
if (fs.existsSync(localPath)) {
|
|
40
|
+
envLocalPaths.push(localPath);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isGitRoot = fs.existsSync(path.join(dir, '.git'));
|
|
45
|
+
if (isGitRoot) break;
|
|
46
|
+
|
|
47
|
+
const parent = path.dirname(dir);
|
|
48
|
+
if (parent === dir) break; // filesystem root
|
|
49
|
+
dir = parent;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// cwd-closest is pushed last during walk-up, so reversing puts it last.
|
|
53
|
+
// (During walk-up cwd is checked first → pushed first → reversed → ends up last.)
|
|
54
|
+
schemaPaths.reverse();
|
|
55
|
+
envLocalPaths.reverse();
|
|
56
|
+
|
|
57
|
+
let companySlug: string | null = null;
|
|
58
|
+
let companySlugPath: string | null = null;
|
|
59
|
+
let conflict: SchemaConflict | null = null;
|
|
60
|
+
|
|
61
|
+
for (const schemaPath of schemaPaths) {
|
|
62
|
+
const slug = parseSlug(schemaPath);
|
|
63
|
+
if (slug == null) continue;
|
|
64
|
+
|
|
65
|
+
if (companySlug == null) {
|
|
66
|
+
companySlug = slug;
|
|
67
|
+
companySlugPath = schemaPath;
|
|
68
|
+
} else if (companySlug !== slug) {
|
|
69
|
+
conflict = {
|
|
70
|
+
paths: [companySlugPath!, schemaPath],
|
|
71
|
+
slugs: [companySlug, slug],
|
|
72
|
+
};
|
|
73
|
+
companySlug = null;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { schemaPaths, envLocalPaths, companySlug, conflict };
|
|
79
|
+
}
|