@smoothbricks/cli 0.1.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 +289 -0
- package/bin/smoo +4 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +141 -0
- package/dist/github-ci/index.d.ts +11 -0
- package/dist/github-ci/index.d.ts.map +1 -0
- package/dist/github-ci/index.js +174 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/json.d.ts +13 -0
- package/dist/lib/json.d.ts.map +1 -0
- package/dist/lib/json.js +57 -0
- package/dist/lib/run.d.ts +5 -0
- package/dist/lib/run.d.ts.map +1 -0
- package/dist/lib/run.js +64 -0
- package/dist/lib/workspace.d.ts +23 -0
- package/dist/lib/workspace.d.ts.map +1 -0
- package/dist/lib/workspace.js +101 -0
- package/dist/monorepo/commit-msg.d.ts +2 -0
- package/dist/monorepo/commit-msg.d.ts.map +1 -0
- package/dist/monorepo/commit-msg.js +34 -0
- package/dist/monorepo/git-config.d.ts +2 -0
- package/dist/monorepo/git-config.d.ts.map +1 -0
- package/dist/monorepo/git-config.js +37 -0
- package/dist/monorepo/index.d.ts +19 -0
- package/dist/monorepo/index.d.ts.map +1 -0
- package/dist/monorepo/index.js +59 -0
- package/dist/monorepo/lockfile.d.ts +2 -0
- package/dist/monorepo/lockfile.d.ts.map +1 -0
- package/dist/monorepo/lockfile.js +34 -0
- package/dist/monorepo/managed-files.d.ts +8 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -0
- package/dist/monorepo/managed-files.js +130 -0
- package/dist/monorepo/nx-sync.d.ts +2 -0
- package/dist/monorepo/nx-sync.d.ts.map +1 -0
- package/dist/monorepo/nx-sync.js +5 -0
- package/dist/monorepo/package-hygiene.d.ts +3 -0
- package/dist/monorepo/package-hygiene.d.ts.map +1 -0
- package/dist/monorepo/package-hygiene.js +12 -0
- package/dist/monorepo/package-policy.d.ts +8 -0
- package/dist/monorepo/package-policy.d.ts.map +1 -0
- package/dist/monorepo/package-policy.js +279 -0
- package/dist/monorepo/packed-package.d.ts +2 -0
- package/dist/monorepo/packed-package.d.ts.map +1 -0
- package/dist/monorepo/packed-package.js +92 -0
- package/dist/monorepo/packs/index.d.ts +10 -0
- package/dist/monorepo/packs/index.d.ts.map +1 -0
- package/dist/monorepo/packs/index.js +87 -0
- package/dist/monorepo/runtime.d.ts +2 -0
- package/dist/monorepo/runtime.d.ts.map +1 -0
- package/dist/monorepo/runtime.js +37 -0
- package/dist/release/index.d.ts +24 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +325 -0
- package/managed/raw/.git-format-staged.yml +47 -0
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +66 -0
- package/managed/raw/tooling/git-hooks/commit-msg.sh +9 -0
- package/managed/raw/tooling/git-hooks/pre-commit.sh +19 -0
- package/managed/templates/github/actions/cache-nix-devenv/action.yml +79 -0
- package/managed/templates/github/actions/cache-node-modules/action.yml +13 -0
- package/managed/templates/github/actions/cache-nx/action.yml +21 -0
- package/managed/templates/github/actions/save-nix-devenv/action.yml +39 -0
- package/managed/templates/github/actions/setup-devenv/action.yml +63 -0
- package/managed/templates/github/workflows/ci.yml +98 -0
- package/managed/templates/github/workflows/publish.yml +136 -0
- package/package.json +68 -0
- package/src/cli.ts +149 -0
- package/src/github-ci/index.ts +196 -0
- package/src/index.ts +1 -0
- package/src/lib/json.ts +68 -0
- package/src/lib/run.ts +76 -0
- package/src/lib/workspace.ts +125 -0
- package/src/monorepo/commit-msg.ts +35 -0
- package/src/monorepo/git-config.ts +43 -0
- package/src/monorepo/index.ts +78 -0
- package/src/monorepo/lockfile.ts +37 -0
- package/src/monorepo/managed-files.ts +153 -0
- package/src/monorepo/nx-sync.ts +6 -0
- package/src/monorepo/package-hygiene.ts +14 -0
- package/src/monorepo/package-policy.ts +307 -0
- package/src/monorepo/packed-package.ts +101 -0
- package/src/monorepo/packs/index.ts +115 -0
- package/src/monorepo/runtime.ts +41 -0
- package/src/release/index.ts +394 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { appendFile, mkdtemp, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { $ } from 'bun';
|
|
5
|
+
import { decode, run, runStatus } from '../lib/run.js';
|
|
6
|
+
|
|
7
|
+
export async function cleanupGithubCiCache(root: string): Promise<void> {
|
|
8
|
+
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
9
|
+
const markCacheReady = async (ready: boolean): Promise<void> => {
|
|
10
|
+
if (githubOutput) {
|
|
11
|
+
await appendFile(githubOutput, `cache-ready=${ready ? 'true' : 'false'}\n`);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const nar = process.env.NIX_STORE_NAR;
|
|
16
|
+
if (!nar) {
|
|
17
|
+
console.warn('NIX_STORE_NAR is not set; skipping Nix cache save.');
|
|
18
|
+
await markCacheReady(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const nixStore = '/nix/var/nix/profiles/default/bin/nix-store';
|
|
22
|
+
const devenvProfile = `${root}/tooling/direnv/.devenv/profile`;
|
|
23
|
+
if (!existsSync(devenvProfile)) {
|
|
24
|
+
console.warn(`${devenvProfile} is missing; skipping Nix cache save.`);
|
|
25
|
+
await markCacheReady(false);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await runStatus(nixStore, ['--verify', '--check-contents', '--repair'], root);
|
|
29
|
+
await exportNixStoreCache(root, nar, nixStore, devenvProfile);
|
|
30
|
+
await markCacheReady(true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function exportNixStoreCache(root: string, nar: string, nixStore: string, devenvProfile: string): Promise<void> {
|
|
34
|
+
const gcRootDir = '/nix/var/nix/gcroots/smoothbricks-cache-roots';
|
|
35
|
+
const tmpDir = await mkdtemp(join(dirname(nar), '.smoo-nix-cache-'));
|
|
36
|
+
const tmpNar = join(tmpDir, 'nix-store.nar');
|
|
37
|
+
const roots = new Set<string>();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await $`rm -f ${nar}`.cwd(root);
|
|
41
|
+
await $`sudo rm -rf ${gcRootDir}`.cwd(root);
|
|
42
|
+
await $`sudo mkdir -p ${gcRootDir}`.cwd(root);
|
|
43
|
+
|
|
44
|
+
// The Nix cache must include every live store path referenced by the
|
|
45
|
+
// restored shell state, not just the devenv profile. .direnv stores paths to
|
|
46
|
+
// derivations like devenv-shell.drv; omitting those makes a cache hit restore
|
|
47
|
+
// metadata that points at missing store paths.
|
|
48
|
+
await addRoot(roots, devenvProfile);
|
|
49
|
+
const home = process.env.HOME;
|
|
50
|
+
if (home) {
|
|
51
|
+
const nixProfile = join(home, '.nix-profile');
|
|
52
|
+
await addRoot(roots, nixProfile);
|
|
53
|
+
await addReferencesFrom(roots, nixProfile, root);
|
|
54
|
+
}
|
|
55
|
+
await addReferencesFrom(roots, join(root, 'tooling/direnv/.devenv'), root);
|
|
56
|
+
await addReferencesFrom(roots, join(root, 'tooling/direnv/.direnv'), root);
|
|
57
|
+
|
|
58
|
+
const rootLinks: string[] = [];
|
|
59
|
+
let index = 0;
|
|
60
|
+
for (const target of roots) {
|
|
61
|
+
const link = `${gcRootDir}/root-${index}`;
|
|
62
|
+
await $`sudo ln -s ${target} ${link}`.cwd(root);
|
|
63
|
+
rootLinks.push(link);
|
|
64
|
+
index += 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (rootLinks.length === 0) {
|
|
68
|
+
throw new Error('No live Nix store roots found; skipping Nix cache save.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await $`nix-collect-garbage --quiet`.cwd(root);
|
|
72
|
+
const closureOutput = await $`sudo ${nixStore} -qR ${rootLinks}`.cwd(root).quiet();
|
|
73
|
+
const closure = decode(closureOutput.stdout)
|
|
74
|
+
.split('\n')
|
|
75
|
+
.map((line) => line.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
if (closure.length === 0) {
|
|
78
|
+
throw new Error('No Nix store closure paths found; skipping Nix cache save.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await $`sudo ${nixStore} --export --quiet ${closure} > ${tmpNar}`.cwd(root);
|
|
82
|
+
await $`test -s ${tmpNar}`.cwd(root);
|
|
83
|
+
await rename(tmpNar, nar);
|
|
84
|
+
} finally {
|
|
85
|
+
await $`sudo rm -rf ${gcRootDir}`.cwd(root).nothrow();
|
|
86
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function addRoot(roots: Set<string>, candidate: string): Promise<void> {
|
|
91
|
+
if (!existsSync(candidate)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const target = await realpath(candidate);
|
|
95
|
+
if (existsSync(target)) {
|
|
96
|
+
roots.add(target);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function addReferencesFrom(roots: Set<string>, path: string, cwd: string): Promise<void> {
|
|
101
|
+
if (!existsSync(path)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const storePathPattern = '/nix/store/[a-z0-9]{32}-[A-Za-z0-9+._?=-]+';
|
|
105
|
+
const result = await $`grep -rahoE ${storePathPattern} ${path}`.cwd(cwd).quiet().nothrow();
|
|
106
|
+
for (const line of decode(result.stdout).split('\n')) {
|
|
107
|
+
const candidate = line.trim();
|
|
108
|
+
if (candidate && existsSync(candidate)) {
|
|
109
|
+
roots.add(candidate);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function githubCiNxSmart(
|
|
115
|
+
root: string,
|
|
116
|
+
options: { target: string; name?: string; step?: string },
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const name = options.name ?? options.target;
|
|
119
|
+
const step = options.step ?? '';
|
|
120
|
+
await createGithubStatus(name, step);
|
|
121
|
+
const mode =
|
|
122
|
+
process.env.GITHUB_EVENT_NAME === 'push' && process.env.GITHUB_REF_NAME === 'main' ? 'run-many' : 'affected';
|
|
123
|
+
const nxArgs =
|
|
124
|
+
mode === 'run-many'
|
|
125
|
+
? ['run-many', '-t', options.target, '--parallel=5']
|
|
126
|
+
: ['affected', '-t', options.target, '--parallel=5'];
|
|
127
|
+
const status = await runStatus('nx', nxArgs, root);
|
|
128
|
+
await updateGithubStatus(name, status === 0 ? 'success' : 'failure', step);
|
|
129
|
+
if (status !== 0) {
|
|
130
|
+
throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function githubCiNxRunMany(root: string, options: { targets: string; projects?: string }): Promise<void> {
|
|
135
|
+
const nxArgs = ['run-many', '-t', options.targets, '--parallel=5'];
|
|
136
|
+
if (options.projects) {
|
|
137
|
+
nxArgs.push(`--projects=${options.projects}`);
|
|
138
|
+
}
|
|
139
|
+
await run('nx', nxArgs, root);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function createGithubStatus(name: string, step: string): Promise<void> {
|
|
143
|
+
await postGithubStatus(name, 'pending', `Running ${name}...`, step);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function updateGithubStatus(name: string, state: 'success' | 'failure' | 'error', step: string): Promise<void> {
|
|
147
|
+
const suffix = state === 'success' ? 'passed' : state === 'failure' ? 'failed' : 'errored';
|
|
148
|
+
await postGithubStatus(name, state, `${name} ${suffix}`, step);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function postGithubStatus(name: string, state: string, description: string, step: string): Promise<void> {
|
|
152
|
+
const repository = process.env.GITHUB_REPOSITORY;
|
|
153
|
+
const sha = process.env.GITHUB_SHA;
|
|
154
|
+
if (!repository || !sha) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const targetUrl = await getGithubStepUrl(step);
|
|
158
|
+
const args = [
|
|
159
|
+
'api',
|
|
160
|
+
'--method',
|
|
161
|
+
'POST',
|
|
162
|
+
'-H',
|
|
163
|
+
'Accept: application/vnd.github+json',
|
|
164
|
+
`/repos/${repository}/statuses/${sha}`,
|
|
165
|
+
'-f',
|
|
166
|
+
`state=${state}`,
|
|
167
|
+
'-f',
|
|
168
|
+
`context=${name}`,
|
|
169
|
+
'-f',
|
|
170
|
+
`description=${description}`,
|
|
171
|
+
];
|
|
172
|
+
if (targetUrl) {
|
|
173
|
+
args.push('-f', `target_url=${targetUrl}`);
|
|
174
|
+
}
|
|
175
|
+
await run('gh', args, process.cwd());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function getGithubStepUrl(step: string): Promise<string | null> {
|
|
179
|
+
const repository = process.env.GITHUB_REPOSITORY;
|
|
180
|
+
const runId = process.env.GITHUB_RUN_ID;
|
|
181
|
+
const job = process.env.GITHUB_JOB;
|
|
182
|
+
if (!repository || !runId || !job) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const result =
|
|
186
|
+
await $`gh api -H ${'Accept: application/vnd.github+json'} ${`/repos/${repository}/actions/runs/${runId}/jobs`} --jq ${`.jobs[] | select(.name == "${job}") | .id`}`
|
|
187
|
+
.quiet()
|
|
188
|
+
.nothrow();
|
|
189
|
+
const jobId = decode(result.stdout).trim();
|
|
190
|
+
if (!jobId) {
|
|
191
|
+
return `https://github.com/${repository}/actions/runs/${runId}`;
|
|
192
|
+
}
|
|
193
|
+
return step
|
|
194
|
+
? `https://github.com/${repository}/actions/runs/${runId}/job/${jobId}#step:${step}:1`
|
|
195
|
+
: `https://github.com/${repository}/actions/runs/${runId}/job/${jobId}`;
|
|
196
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runCli } from './cli.js';
|
package/src/lib/json.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { hasOwn, hasOwnString, isRecord } from '@smoothbricks/validation';
|
|
3
|
+
|
|
4
|
+
export { hasOwn, hasOwnString, isRecord };
|
|
5
|
+
|
|
6
|
+
export function stringProperty(record: Record<string, unknown>, key: string): string | null {
|
|
7
|
+
const value = record[key];
|
|
8
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function recordProperty(record: Record<string, unknown>, key: string): Record<string, unknown> | null {
|
|
12
|
+
const value = record[key];
|
|
13
|
+
return isRecord(value) ? value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getOrCreateRecord(record: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
17
|
+
const value = record[key];
|
|
18
|
+
if (isRecord(value)) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
const next: Record<string, unknown> = {};
|
|
22
|
+
record[key] = next;
|
|
23
|
+
return next;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setStringProperty(record: Record<string, unknown>, key: string, value: string): boolean {
|
|
27
|
+
if (record[key] === value) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
record[key] = value;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setMissingStringProperty(record: Record<string, unknown>, key: string, value: string): boolean {
|
|
35
|
+
if (typeof record[key] === 'string') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
record[key] = value;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function requiredJsonObject(path: string): Record<string, unknown> {
|
|
43
|
+
const json = readJsonObject(path);
|
|
44
|
+
if (!json) {
|
|
45
|
+
throw new Error(`${path} not found or invalid`);
|
|
46
|
+
}
|
|
47
|
+
return json;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readJsonObject(path: string): Record<string, unknown> | null {
|
|
51
|
+
const json = readJson(path);
|
|
52
|
+
return isRecord(json) ? json : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function writeJsonObject(path: string, value: Record<string, unknown>): void {
|
|
56
|
+
writeFileSync(path, jsonObjectText(value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function jsonObjectText(value: Record<string, unknown>): string {
|
|
60
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readJson(path: string): unknown {
|
|
64
|
+
if (!existsSync(path)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
68
|
+
}
|
package/src/lib/run.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { $ } from 'bun';
|
|
5
|
+
|
|
6
|
+
export async function run(command: string, args: string[], cwd: string, env?: Record<string, string>): Promise<void> {
|
|
7
|
+
const status = await runStatus(command, args, cwd, false, env);
|
|
8
|
+
if (status !== 0) {
|
|
9
|
+
throw new Error(`${command} ${args.join(' ')} failed with exit code ${status}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runStatus(
|
|
14
|
+
command: string,
|
|
15
|
+
args: string[],
|
|
16
|
+
cwd: string,
|
|
17
|
+
quiet = false,
|
|
18
|
+
env?: Record<string, string>,
|
|
19
|
+
): Promise<number> {
|
|
20
|
+
const invocation = resolveCommandInvocation(cwd, command, args);
|
|
21
|
+
let shell = $`${invocation.command} ${invocation.args}`.cwd(cwd).nothrow();
|
|
22
|
+
if (env) {
|
|
23
|
+
shell = shell.env(mergeEnv(env));
|
|
24
|
+
}
|
|
25
|
+
const result = quiet ? await shell.quiet() : await shell;
|
|
26
|
+
return result.exitCode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mergeEnv(env: Record<string, string>): Record<string, string> {
|
|
30
|
+
const merged: Record<string, string> = {};
|
|
31
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
32
|
+
if (value !== undefined) {
|
|
33
|
+
merged[key] = value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { ...merged, ...env };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveCommandInvocation(root: string, command: string, args: string[]): { command: string; args: string[] } {
|
|
40
|
+
const localCommand = join(root, 'node_modules', '.bin', command);
|
|
41
|
+
if (existsSync(localCommand)) {
|
|
42
|
+
return { command: localCommand, args };
|
|
43
|
+
}
|
|
44
|
+
const bundledCommand = resolveBundledCommand(command);
|
|
45
|
+
if (bundledCommand) {
|
|
46
|
+
return { command: 'bun', args: [bundledCommand, ...args] };
|
|
47
|
+
}
|
|
48
|
+
return { command, args };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveBundledCommand(command: string): string | null {
|
|
52
|
+
try {
|
|
53
|
+
if (command === 'sherif') {
|
|
54
|
+
return fileURLToPath(import.meta.resolve('sherif'));
|
|
55
|
+
}
|
|
56
|
+
if (command === 'attw') {
|
|
57
|
+
const packageJson = fileURLToPath(import.meta.resolve('@arethetypeswrong/cli/package.json'));
|
|
58
|
+
return join(dirname(packageJson), 'dist', 'index.js');
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function findRepoRoot(): Promise<string> {
|
|
67
|
+
const result = await $`git rev-parse --show-toplevel`.cwd(process.cwd()).quiet().nothrow();
|
|
68
|
+
if (result.exitCode === 0) {
|
|
69
|
+
return decode(result.stdout).trim();
|
|
70
|
+
}
|
|
71
|
+
return process.cwd();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function decode(bytes: Uint8Array): string {
|
|
75
|
+
return new TextDecoder().decode(bytes);
|
|
76
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import { hasOwn, hasOwnString, isRecord, readJson, readJsonObject, stringProperty } from './json.js';
|
|
4
|
+
|
|
5
|
+
export interface PackageInfo {
|
|
6
|
+
name: string;
|
|
7
|
+
version: string;
|
|
8
|
+
private: boolean;
|
|
9
|
+
tags: string[];
|
|
10
|
+
path: string;
|
|
11
|
+
packageJsonPath: string;
|
|
12
|
+
json: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RepositoryInfo {
|
|
16
|
+
type: string;
|
|
17
|
+
url: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const workspaceDependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies'] as const;
|
|
21
|
+
|
|
22
|
+
export function listPublicPackages(root: string): PackageInfo[] {
|
|
23
|
+
return getWorkspacePackages(root).filter((pkg) => !pkg.private && pkg.tags.includes('npm:public'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getWorkspacePackages(root: string): PackageInfo[] {
|
|
27
|
+
if (!readPackageJson(join(root, 'package.json'))) {
|
|
28
|
+
throw new Error('package.json not found or invalid');
|
|
29
|
+
}
|
|
30
|
+
const workspacePatterns = getWorkspacePatterns(root);
|
|
31
|
+
const packages: PackageInfo[] = [];
|
|
32
|
+
for (const pattern of workspacePatterns) {
|
|
33
|
+
if (!pattern.endsWith('/*')) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const parent = join(root, pattern.slice(0, -2));
|
|
37
|
+
if (!statSync(parent, { throwIfNoEntry: false })?.isDirectory()) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const entry of readdirSync(parent)) {
|
|
41
|
+
const pkgPath = join(parent, entry, 'package.json');
|
|
42
|
+
const pkg = readPackageJson(pkgPath);
|
|
43
|
+
if (!pkg?.name || !pkg.version) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
packages.push({
|
|
47
|
+
name: pkg.name,
|
|
48
|
+
version: pkg.version,
|
|
49
|
+
private: pkg.private,
|
|
50
|
+
tags: pkg.tags,
|
|
51
|
+
path: relative(root, dirname(pkgPath)),
|
|
52
|
+
packageJsonPath: pkg.packageJsonPath,
|
|
53
|
+
json: pkg.json,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function listPackageJsonRecords(root: string): PackageInfo[] {
|
|
61
|
+
const rootPackage = readPackageJson(join(root, 'package.json'));
|
|
62
|
+
if (!rootPackage) {
|
|
63
|
+
throw new Error('package.json not found or invalid');
|
|
64
|
+
}
|
|
65
|
+
return [{ ...rootPackage, path: '.' }, ...getWorkspacePackages(root)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getWorkspacePatterns(root: string): string[] {
|
|
69
|
+
const raw = readJson(join(root, 'package.json'));
|
|
70
|
+
if (!isRecord(raw) || !hasOwn(raw, 'workspaces')) {
|
|
71
|
+
return ['packages/*'];
|
|
72
|
+
}
|
|
73
|
+
const workspaces = raw.workspaces;
|
|
74
|
+
if (Array.isArray(workspaces)) {
|
|
75
|
+
return workspaces.filter((entry): entry is string => typeof entry === 'string');
|
|
76
|
+
}
|
|
77
|
+
if (isRecord(workspaces) && hasOwn(workspaces, 'packages') && Array.isArray(workspaces.packages)) {
|
|
78
|
+
return workspaces.packages.filter((entry): entry is string => typeof entry === 'string');
|
|
79
|
+
}
|
|
80
|
+
return ['packages/*'];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readPackageJson(path: string): PackageInfo | null {
|
|
84
|
+
const parsed = readJsonObject(path);
|
|
85
|
+
if (!isRecord(parsed) || !hasOwnString(parsed, 'name') || !hasOwnString(parsed, 'version')) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const privateValue = hasOwn(parsed, 'private') && typeof parsed.private === 'boolean' ? parsed.private : false;
|
|
89
|
+
const tags = getNxTags(parsed);
|
|
90
|
+
return {
|
|
91
|
+
name: parsed.name,
|
|
92
|
+
version: parsed.version,
|
|
93
|
+
private: privateValue,
|
|
94
|
+
tags,
|
|
95
|
+
path: dirname(path),
|
|
96
|
+
packageJsonPath: path,
|
|
97
|
+
json: parsed,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getNxTags(pkg: Record<string, unknown>): string[] {
|
|
102
|
+
if (!hasOwn(pkg, 'nx') || !isRecord(pkg.nx) || !hasOwn(pkg.nx, 'tags') || !Array.isArray(pkg.nx.tags)) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return pkg.nx.tags.filter((tag): tag is string => typeof tag === 'string');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function repositoryInfo(pkg: Record<string, unknown>): RepositoryInfo | null {
|
|
109
|
+
const repository = pkg.repository;
|
|
110
|
+
if (typeof repository === 'string') {
|
|
111
|
+
return { type: 'git', url: repository };
|
|
112
|
+
}
|
|
113
|
+
if (!isRecord(repository)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const url = stringProperty(repository, 'url');
|
|
117
|
+
if (!url) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return { type: stringProperty(repository, 'type') ?? 'git', url };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function escapeRegex(value: string): string {
|
|
124
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
125
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const validCommitTypes = new Set([
|
|
2
|
+
'build',
|
|
3
|
+
'chore',
|
|
4
|
+
'ci',
|
|
5
|
+
'docs',
|
|
6
|
+
'feat',
|
|
7
|
+
'fix',
|
|
8
|
+
'perf',
|
|
9
|
+
'refactor',
|
|
10
|
+
'revert',
|
|
11
|
+
'style',
|
|
12
|
+
'test',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export function validateCommitMessage(message: string): string | null {
|
|
16
|
+
const subject = message.split('\n', 1)[0]?.trim() ?? '';
|
|
17
|
+
if (!subject) {
|
|
18
|
+
return 'Commit message subject is empty.';
|
|
19
|
+
}
|
|
20
|
+
if (/^(Merge|Revert ")/.test(subject) || /^(fixup|squash)! /.test(subject)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const match = /^(?<type>[a-z]+)(\([a-z0-9._/-]+\))?(?<breaking>!)?: .+$/.exec(subject);
|
|
24
|
+
const type = match?.groups?.type;
|
|
25
|
+
if (type && validCommitTypes.has(type)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return `Invalid conventional commit subject: ${subject}
|
|
29
|
+
|
|
30
|
+
Expected examples:
|
|
31
|
+
feat(statebus-core): add optimistic transactions
|
|
32
|
+
fix(money): round negative amounts consistently
|
|
33
|
+
chore(release): publish 1.2.3
|
|
34
|
+
feat!: remove deprecated API`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { $ } from 'bun';
|
|
4
|
+
import { decode } from '../lib/run.js';
|
|
5
|
+
|
|
6
|
+
export async function applyWorkspaceGitConfig(root: string): Promise<void> {
|
|
7
|
+
const gitDirResult = await $`git rev-parse --git-dir`.cwd(root).quiet().nothrow();
|
|
8
|
+
if (gitDirResult.exitCode !== 0) {
|
|
9
|
+
throw new Error('Not in a git repository');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const gitDir = resolve(root, decode(gitDirResult.stdout).trim());
|
|
13
|
+
const tooling = join(root, 'tooling');
|
|
14
|
+
|
|
15
|
+
await $`git config --local include.path ${join(tooling, 'workspace.gitconfig')}`.cwd(root);
|
|
16
|
+
linkHook(gitDir, tooling, 'pre-commit');
|
|
17
|
+
linkHook(gitDir, tooling, 'commit-msg');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function linkHook(gitDir: string, tooling: string, name: string): void {
|
|
21
|
+
const source = join(tooling, 'git-hooks', `${name}.sh`);
|
|
22
|
+
if (!existsSync(source)) {
|
|
23
|
+
throw new Error(`Missing ${name} hook source: ${source}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const target = join(gitDir, 'hooks', name);
|
|
27
|
+
if (readLinkOrNull(target) === source) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(`[!] Linking ${name} hook in ${gitDir}`);
|
|
32
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
33
|
+
rmSync(target, { force: true });
|
|
34
|
+
symlinkSync(source, target);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readLinkOrNull(path: string): string | null {
|
|
38
|
+
try {
|
|
39
|
+
return readlinkSync(path);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { listPublicPackages } from '../lib/workspace.js';
|
|
3
|
+
import { validateCommitMessage } from './commit-msg.js';
|
|
4
|
+
import { applyWorkspaceGitConfig } from './git-config.js';
|
|
5
|
+
import { syncBunLockfileVersions } from './lockfile.js';
|
|
6
|
+
import { applyManagedFiles, printResults } from './managed-files.js';
|
|
7
|
+
import { validatePublicTags } from './package-policy.js';
|
|
8
|
+
import { runInitPacks, runValidatePacks } from './packs/index.js';
|
|
9
|
+
import { syncRootRuntimeVersions } from './runtime.js';
|
|
10
|
+
|
|
11
|
+
export interface InitOptions {
|
|
12
|
+
runtimeOnly?: boolean;
|
|
13
|
+
syncRuntime?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ValidateOptions {
|
|
17
|
+
failFast?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function initMonorepo(root: string, options: InitOptions): Promise<void> {
|
|
21
|
+
if (options.runtimeOnly) {
|
|
22
|
+
await syncRootRuntimeVersions(root);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
printResults(applyManagedFiles(root, 'update'));
|
|
27
|
+
await runInitPacks({ root, syncRuntime: process.env.DEVENV_ROOT !== undefined || options.syncRuntime === true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function validateMonorepo(root: string, options: ValidateOptions = {}): Promise<void> {
|
|
31
|
+
const failures = await runValidatePacks({ root, syncRuntime: false }, options);
|
|
32
|
+
if (failures > 0) {
|
|
33
|
+
throw new Error(`Monorepo validation failed with ${failures} problem(s).`);
|
|
34
|
+
}
|
|
35
|
+
console.log('\n== summary ==');
|
|
36
|
+
console.log('Monorepo configuration is valid.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function updateManagedFiles(root: string): void {
|
|
40
|
+
printResults(applyManagedFiles(root, 'update'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function checkManagedFiles(root: string): void {
|
|
44
|
+
const results = applyManagedFiles(root, 'check');
|
|
45
|
+
printResults(results);
|
|
46
|
+
if (results.some((result) => result.action === 'drifted')) {
|
|
47
|
+
throw new Error('Managed monorepo files are out of date. Run: smoo monorepo update');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function diffManagedFiles(root: string): void {
|
|
52
|
+
printResults(applyManagedFiles(root, 'diff'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateCommitMessageFile(path: string | undefined): void {
|
|
56
|
+
if (!path) {
|
|
57
|
+
throw new Error('Usage: smoo monorepo validate-commit-msg <commit-msg-file>');
|
|
58
|
+
}
|
|
59
|
+
const message = readFileSync(path, 'utf8');
|
|
60
|
+
const error = validateCommitMessage(message);
|
|
61
|
+
if (error) {
|
|
62
|
+
throw new Error(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function listPublicProjects(root: string): string {
|
|
67
|
+
return listPublicPackages(root)
|
|
68
|
+
.map((pkg) => pkg.name)
|
|
69
|
+
.join(',');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validatePublicPackageTags(root: string): void {
|
|
73
|
+
if (validatePublicTags(root) > 0) {
|
|
74
|
+
throw new Error('npm:public tag validation failed.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { applyWorkspaceGitConfig, syncBunLockfileVersions };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { escapeRegex, getWorkspacePackages } from '../lib/workspace.js';
|
|
4
|
+
|
|
5
|
+
export function syncBunLockfileVersions(root: string): void {
|
|
6
|
+
const lockfilePath = join(root, 'bun.lock');
|
|
7
|
+
if (!existsSync(lockfilePath)) {
|
|
8
|
+
throw new Error('bun.lock not found');
|
|
9
|
+
}
|
|
10
|
+
const packages = getWorkspacePackages(root);
|
|
11
|
+
let lockfile = readFileSync(lockfilePath, 'utf8');
|
|
12
|
+
let updated = 0;
|
|
13
|
+
for (const pkg of packages) {
|
|
14
|
+
const relativePath = pkg.path.replaceAll('\\', '/');
|
|
15
|
+
const escaped = escapeRegex(relativePath);
|
|
16
|
+
const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
|
|
17
|
+
const match = lockfile.match(pattern);
|
|
18
|
+
if (!match) {
|
|
19
|
+
console.log(`skip: ${relativePath} (not found in lockfile)`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const lockVersion = match[2];
|
|
23
|
+
if (lockVersion === pkg.version) {
|
|
24
|
+
console.log(`ok: ${relativePath} = ${pkg.version}`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
lockfile = lockfile.replace(pattern, `$1${pkg.version}$3`);
|
|
28
|
+
console.log(`fix: ${relativePath}: ${lockVersion} -> ${pkg.version}`);
|
|
29
|
+
updated++;
|
|
30
|
+
}
|
|
31
|
+
if (updated > 0) {
|
|
32
|
+
writeFileSync(lockfilePath, lockfile);
|
|
33
|
+
}
|
|
34
|
+
console.log(
|
|
35
|
+
updated > 0 ? `Updated ${updated} workspace version(s) in bun.lock` : 'All workspace versions already in sync.',
|
|
36
|
+
);
|
|
37
|
+
}
|