@doki-land/nifty 0.0.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 +30 -0
- package/cli/nifty.mjs +11 -0
- package/package.json +35 -0
- package/src/cli/authArgs.ts +52 -0
- package/src/cli/bump.ts +237 -0
- package/src/cli/context.ts +26 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/lint.ts +109 -0
- package/src/cli/main.ts +64 -0
- package/src/cli/publish.ts +85 -0
- package/src/cli/resolveNpm.ts +16 -0
- package/src/cli/trust.ts +77 -0
- package/src/cli/update.ts +182 -0
- package/src/cli/upload.ts +280 -0
- package/src/cli/workspace.ts +124 -0
- package/src/config/defineConfig.ts +12 -0
- package/src/config/detectProject.ts +189 -0
- package/src/config/index.ts +25 -0
- package/src/config/loadConfig.ts +129 -0
- package/src/config/types.ts +43 -0
- package/src/git.ts +61 -0
- package/src/github.ts +184 -0
- package/src/gitmoji.ts +77 -0
- package/src/index.ts +36 -0
- package/src/native.ts +277 -0
- package/src/nifty.ts +95 -0
- package/src/types.ts +40 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ProjectKind = "cargo" | "npm" | "hybrid" | "unknown";
|
|
5
|
+
|
|
6
|
+
/** Detected cargo + npm project layout. */
|
|
7
|
+
export type ProjectLayout = {
|
|
8
|
+
/** Resolved project root (git root when present). */
|
|
9
|
+
root: string;
|
|
10
|
+
kind: ProjectKind;
|
|
11
|
+
/** Nearest `Cargo.toml` walking upward. */
|
|
12
|
+
cargoManifest?: string;
|
|
13
|
+
/** Nearest `package.json` walking upward (skips `node_modules`). */
|
|
14
|
+
packageManifest?: string;
|
|
15
|
+
/** Directory whose `Cargo.toml` declares a `[workspace]`. */
|
|
16
|
+
cargoWorkspaceRoot?: string;
|
|
17
|
+
/** Directory whose `package.json` declares npm `workspaces`. */
|
|
18
|
+
npmWorkspaceRoot?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const CARGO_MANIFEST = "Cargo.toml";
|
|
22
|
+
const PACKAGE_MANIFEST = "package.json";
|
|
23
|
+
const GIT_DIR = ".git";
|
|
24
|
+
|
|
25
|
+
function isInsideNodeModules(path: string): boolean {
|
|
26
|
+
return path.split(/[/\\]/).includes("node_modules");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findFileUpward(cwd: string, fileName: string, skipNodeModules: boolean): string | undefined {
|
|
30
|
+
let here = resolve(cwd);
|
|
31
|
+
while (true) {
|
|
32
|
+
if (skipNodeModules && isInsideNodeModules(here)) {
|
|
33
|
+
const parent = dirname(here);
|
|
34
|
+
if (parent === here) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
here = parent;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const candidate = join(here, fileName);
|
|
41
|
+
if (existsSync(candidate)) {
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
const parent = dirname(here);
|
|
45
|
+
if (parent === here) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
here = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findGitRoot(cwd: string): string | undefined {
|
|
53
|
+
let here = resolve(cwd);
|
|
54
|
+
while (true) {
|
|
55
|
+
if (existsSync(join(here, GIT_DIR))) {
|
|
56
|
+
return here;
|
|
57
|
+
}
|
|
58
|
+
const parent = dirname(here);
|
|
59
|
+
if (parent === here) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
here = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function manifestHasCargoWorkspace(manifestPath: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
return readFileSync(manifestPath, "utf8")
|
|
69
|
+
.split(/\r?\n/)
|
|
70
|
+
.some((line) => line.trim() === "[workspace]");
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function manifestHasNpmWorkspaces(manifestPath: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
return readFileSync(manifestPath, "utf8").includes('"workspaces"');
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findCargoWorkspaceRoot(manifestPath: string): string | undefined {
|
|
85
|
+
let here = dirname(resolve(manifestPath));
|
|
86
|
+
while (true) {
|
|
87
|
+
const candidate = join(here, CARGO_MANIFEST);
|
|
88
|
+
if (existsSync(candidate) && manifestHasCargoWorkspace(candidate)) {
|
|
89
|
+
return here;
|
|
90
|
+
}
|
|
91
|
+
const parent = dirname(here);
|
|
92
|
+
if (parent === here) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
here = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findNpmWorkspaceRoot(manifestPath: string): string | undefined {
|
|
100
|
+
let here = dirname(resolve(manifestPath));
|
|
101
|
+
while (true) {
|
|
102
|
+
const candidate = join(here, PACKAGE_MANIFEST);
|
|
103
|
+
if (existsSync(candidate) && manifestHasNpmWorkspaces(candidate)) {
|
|
104
|
+
return here;
|
|
105
|
+
}
|
|
106
|
+
const parent = dirname(here);
|
|
107
|
+
if (parent === here) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
here = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function classifyProject(hasCargo: boolean, hasNpm: boolean): ProjectKind {
|
|
115
|
+
if (hasCargo && hasNpm) {
|
|
116
|
+
return "hybrid";
|
|
117
|
+
}
|
|
118
|
+
if (hasCargo) {
|
|
119
|
+
return "cargo";
|
|
120
|
+
}
|
|
121
|
+
if (hasNpm) {
|
|
122
|
+
return "npm";
|
|
123
|
+
}
|
|
124
|
+
return "unknown";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Walk upward and return the nearest `Cargo.toml`. */
|
|
128
|
+
export function findCargoManifest(cwd = process.cwd()): string | undefined {
|
|
129
|
+
return findFileUpward(cwd, CARGO_MANIFEST, false);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Walk upward and return the nearest `package.json` (skips `node_modules`). */
|
|
133
|
+
export function findPackageManifest(cwd = process.cwd()): string | undefined {
|
|
134
|
+
return findFileUpward(cwd, PACKAGE_MANIFEST, true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Detect cargo/npm/hybrid layout from `cwd`. */
|
|
138
|
+
export function detectProjectLayout(cwd = process.cwd()): ProjectLayout {
|
|
139
|
+
const start = resolve(cwd);
|
|
140
|
+
const cargoManifest = findCargoManifest(start);
|
|
141
|
+
const packageManifest = findPackageManifest(start);
|
|
142
|
+
const cargoWorkspaceRoot = cargoManifest ? findCargoWorkspaceRoot(cargoManifest) : undefined;
|
|
143
|
+
const npmWorkspaceRoot = packageManifest ? findNpmWorkspaceRoot(packageManifest) : undefined;
|
|
144
|
+
const kind = classifyProject(Boolean(cargoManifest), Boolean(packageManifest));
|
|
145
|
+
const root =
|
|
146
|
+
findGitRoot(start) ??
|
|
147
|
+
cargoWorkspaceRoot ??
|
|
148
|
+
npmWorkspaceRoot ??
|
|
149
|
+
(cargoManifest ? dirname(cargoManifest) : undefined) ??
|
|
150
|
+
(packageManifest ? dirname(packageManifest) : undefined) ??
|
|
151
|
+
start;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
root,
|
|
155
|
+
kind,
|
|
156
|
+
cargoManifest,
|
|
157
|
+
packageManifest,
|
|
158
|
+
cargoWorkspaceRoot,
|
|
159
|
+
npmWorkspaceRoot,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Discover npm package directories for hybrid monorepos. */
|
|
164
|
+
export function discoverPackageDirs(layout: ProjectLayout): string[] {
|
|
165
|
+
const roots = new Set<string>();
|
|
166
|
+
|
|
167
|
+
if (layout.npmWorkspaceRoot) {
|
|
168
|
+
roots.add(layout.npmWorkspaceRoot);
|
|
169
|
+
}
|
|
170
|
+
if (layout.packageManifest) {
|
|
171
|
+
roots.add(dirname(layout.packageManifest));
|
|
172
|
+
}
|
|
173
|
+
if (layout.kind === "hybrid") {
|
|
174
|
+
const packagesDir = join(layout.root, "projects", "packages");
|
|
175
|
+
if (existsSync(packagesDir)) {
|
|
176
|
+
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
|
|
177
|
+
if (!entry.isDirectory()) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const packageJson = join(packagesDir, entry.name, "package.json");
|
|
181
|
+
if (existsSync(packageJson)) {
|
|
182
|
+
roots.add(join(packagesDir, entry.name));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return [...roots];
|
|
189
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { defineConfig } from "./defineConfig.js";
|
|
2
|
+
export {
|
|
3
|
+
detectProjectLayout,
|
|
4
|
+
discoverPackageDirs,
|
|
5
|
+
findCargoManifest,
|
|
6
|
+
findPackageManifest,
|
|
7
|
+
type ProjectKind,
|
|
8
|
+
type ProjectLayout,
|
|
9
|
+
} from "./detectProject.js";
|
|
10
|
+
export {
|
|
11
|
+
authorMapToJson,
|
|
12
|
+
defaultConfigPath,
|
|
13
|
+
ensureConfigFile,
|
|
14
|
+
findConfigFile,
|
|
15
|
+
loadConfig,
|
|
16
|
+
type LoadConfigOptions,
|
|
17
|
+
type LoadedNiftyConfig,
|
|
18
|
+
} from "./loadConfig.js";
|
|
19
|
+
export {
|
|
20
|
+
CONFIG_FILE_NAMES,
|
|
21
|
+
type NiftyAuthorEntry,
|
|
22
|
+
type NiftyConfig,
|
|
23
|
+
type NiftyConfigEnv,
|
|
24
|
+
type NiftyConfigExport,
|
|
25
|
+
} from "./types.js";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
|
|
5
|
+
import type { NiftyConfig, NiftyConfigEnv, NiftyConfigExport } from "./types.js";
|
|
6
|
+
import { CONFIG_FILE_NAMES } from "./types.js";
|
|
7
|
+
import { detectProjectLayout } from "./detectProject.js";
|
|
8
|
+
|
|
9
|
+
export type LoadConfigOptions = {
|
|
10
|
+
/** Directory to start searching from (defaults to `process.cwd()`). */
|
|
11
|
+
cwd?: string;
|
|
12
|
+
/** When set, skip searching and load this file directly. */
|
|
13
|
+
configFile?: string;
|
|
14
|
+
/** Passed to functional configs: `export default defineConfig(({ mode }) => ...)`. */
|
|
15
|
+
env?: Partial<NiftyConfigEnv>;
|
|
16
|
+
/**
|
|
17
|
+
* When true (default), write `nifty.config.ts` at the detected project root if missing.
|
|
18
|
+
* Set false for `--dry-run` so commands only report what would happen.
|
|
19
|
+
*/
|
|
20
|
+
createIfMissing?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type LoadedNiftyConfig = {
|
|
24
|
+
config: NiftyConfig;
|
|
25
|
+
configFile?: string;
|
|
26
|
+
/** True when this run created a new config file. */
|
|
27
|
+
created?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONFIG_TEMPLATE = `// Nifty project configuration.
|
|
31
|
+
// See @doki-land/nifty for defineConfig, githubToken, authorMap, and lint rules.
|
|
32
|
+
export default {};
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const DEFAULT_ENV = (cwd: string): NiftyConfigEnv => ({
|
|
36
|
+
mode: process.env.NODE_ENV ?? "development",
|
|
37
|
+
command: "serve",
|
|
38
|
+
layout: detectProjectLayout(cwd),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Walk upward and return the nearest existing Nifty config file. */
|
|
42
|
+
export function findConfigFile(cwd = process.cwd()): string | undefined {
|
|
43
|
+
let here = resolve(cwd);
|
|
44
|
+
while (true) {
|
|
45
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
46
|
+
const candidate = join(here, name);
|
|
47
|
+
if (existsSync(candidate)) {
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const parent = dirname(here);
|
|
52
|
+
if (parent === here) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
here = parent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Default config path at the detected project root (prefers `nifty.config.ts`). */
|
|
60
|
+
export function defaultConfigPath(cwd = process.cwd()): string {
|
|
61
|
+
const layout = detectProjectLayout(cwd);
|
|
62
|
+
return join(layout.root, CONFIG_FILE_NAMES[0]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Warn and optionally create a default config when none exists. */
|
|
66
|
+
export function ensureConfigFile(options: { cwd?: string; create?: boolean } = {}): string | undefined {
|
|
67
|
+
const cwd = options.cwd ?? process.cwd();
|
|
68
|
+
const existing = findConfigFile(cwd);
|
|
69
|
+
if (existing) {
|
|
70
|
+
return existing;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const target = defaultConfigPath(cwd);
|
|
74
|
+
if (options.create) {
|
|
75
|
+
writeFileSync(target, DEFAULT_CONFIG_TEMPLATE, "utf8");
|
|
76
|
+
console.warn(`nifty: no config found, created default ${target}`);
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.warn(`nifty: no nifty.config.* found near ${detectProjectLayout(cwd).root} (using defaults)`);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeExport(raw: unknown): NiftyConfigExport {
|
|
85
|
+
if (raw && typeof raw === "object" && "default" in raw) {
|
|
86
|
+
return (raw as { default: NiftyConfigExport }).default;
|
|
87
|
+
}
|
|
88
|
+
return raw as NiftyConfigExport;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveExport(exported: NiftyConfigExport, env: NiftyConfigEnv): Promise<NiftyConfig> {
|
|
92
|
+
if (typeof exported === "function") {
|
|
93
|
+
return await exported(env);
|
|
94
|
+
}
|
|
95
|
+
return exported;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Load `nifty.config.ts/js`, creating a default file at the project root when missing. */
|
|
99
|
+
export async function loadConfig(options: LoadConfigOptions = {}): Promise<LoadedNiftyConfig> {
|
|
100
|
+
const cwd = options.cwd ?? process.cwd();
|
|
101
|
+
let configFile = options.configFile ?? findConfigFile(cwd);
|
|
102
|
+
let created = false;
|
|
103
|
+
|
|
104
|
+
if (!configFile && options.createIfMissing !== false) {
|
|
105
|
+
configFile = ensureConfigFile({ cwd, create: true });
|
|
106
|
+
created = Boolean(configFile);
|
|
107
|
+
} else if (!configFile) {
|
|
108
|
+
ensureConfigFile({ cwd, create: false });
|
|
109
|
+
return { config: {} };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!configFile) {
|
|
113
|
+
return { config: {} };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const env: NiftyConfigEnv = { ...DEFAULT_ENV(cwd), ...options.env };
|
|
117
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
118
|
+
const mod = await jiti.import(configFile);
|
|
119
|
+
const config = await resolveExport(normalizeExport(mod), env);
|
|
120
|
+
return { config, configFile, created };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Serialize `authorMap` for native helpers expecting JSON text. */
|
|
124
|
+
export function authorMapToJson(authorMap: NiftyConfig["authorMap"]): string {
|
|
125
|
+
if (!authorMap || Object.keys(authorMap).length === 0) {
|
|
126
|
+
return "{}";
|
|
127
|
+
}
|
|
128
|
+
return JSON.stringify(authorMap);
|
|
129
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** GitHub author entry in `authorMap` (same shape as `author-github.json`). */
|
|
2
|
+
export type NiftyAuthorEntry = {
|
|
3
|
+
id?: number;
|
|
4
|
+
login?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** Nifty project configuration (`nifty.config.ts` / `nifty.config.js`). */
|
|
8
|
+
export type NiftyConfig = {
|
|
9
|
+
/** GitHub personal access token for email search / profile fetch. */
|
|
10
|
+
githubToken?: string;
|
|
11
|
+
/** Email → GitHub author mapping. */
|
|
12
|
+
authorMap?: Record<string, NiftyAuthorEntry>;
|
|
13
|
+
/** Repository root override when discovering git metadata. */
|
|
14
|
+
repoRoot?: string;
|
|
15
|
+
/** Cargo workspace / crate root override. */
|
|
16
|
+
cargoRoot?: string;
|
|
17
|
+
/** npm package root override. */
|
|
18
|
+
npmRoot?: string;
|
|
19
|
+
/** Rule-based lint configuration. */
|
|
20
|
+
lint?: {
|
|
21
|
+
rules?: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
severity?: "error" | "warning" | "info";
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type NiftyConfigExport = NiftyConfig | ((env: NiftyConfigEnv) => NiftyConfig | Promise<NiftyConfig>);
|
|
30
|
+
|
|
31
|
+
export type NiftyConfigEnv = {
|
|
32
|
+
mode: string;
|
|
33
|
+
command: string;
|
|
34
|
+
/** Detected cargo + npm project layout for hybrid monorepos. */
|
|
35
|
+
layout: import("./detectProject.js").ProjectLayout;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const CONFIG_FILE_NAMES = [
|
|
39
|
+
"nifty.config.ts",
|
|
40
|
+
"nifty.config.js",
|
|
41
|
+
"nifty.config.mjs",
|
|
42
|
+
"nifty.config.cjs",
|
|
43
|
+
] as const;
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CommitRecord, RangeInfo, TagInfo } from "./types.js";
|
|
2
|
+
import { loadNiftyNative, mapCommitRecord, mapRangeInfo, mapTagInfo, type GitExports } from "./native.js";
|
|
3
|
+
|
|
4
|
+
export type ResolveRangeOptions = {
|
|
5
|
+
version?: string;
|
|
6
|
+
fromRef?: string;
|
|
7
|
+
toRef?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read-only git repository helpers (gix via Node-API, no `git` subprocess).
|
|
12
|
+
*/
|
|
13
|
+
export class Git {
|
|
14
|
+
constructor(private readonly wasm: GitExports) {}
|
|
15
|
+
|
|
16
|
+
static open(): Git {
|
|
17
|
+
const native = loadNiftyNative();
|
|
18
|
+
return new Git(native.git);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Walk upward from `startPath` to find the working tree root containing `.git`. */
|
|
22
|
+
discoverRoot(startPath: string): string {
|
|
23
|
+
return this.wasm["discover-root"](startPath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** List `v*` tags sorted by semver. */
|
|
27
|
+
listVersionTags(repoRoot: string): string[] {
|
|
28
|
+
return this.wasm["list-version-tags"](repoRoot);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
listTagInfos(repoRoot: string): TagInfo[] {
|
|
32
|
+
return this.wasm["list-tag-infos"](repoRoot).map(mapTagInfo);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
formatTagList(repoRoot: string): string {
|
|
36
|
+
return this.wasm["format-tag-list"](repoRoot);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resolveRange(repoRoot: string, options: ResolveRangeOptions = {}): RangeInfo {
|
|
40
|
+
return mapRangeInfo(
|
|
41
|
+
this.wasm["resolve-range"](repoRoot, options.version, options.fromRef, options.toRef),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Collect non-merge commits in `fromRef..toRef` (exclusive..inclusive). */
|
|
46
|
+
collectCommits(repoRoot: string, fromRef: string | undefined, toRef: string): CommitRecord[] {
|
|
47
|
+
return this.wasm["collect-commits"](repoRoot, fromRef, toRef).map(mapCommitRecord);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
detectGithubRepo(repoRoot: string): string | undefined {
|
|
51
|
+
return this.wasm["detect-github-repo"](repoRoot);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
parseGithubRemote(url: string): string | undefined {
|
|
55
|
+
return this.wasm["parse-github-remote"](url);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createGit(): Git {
|
|
60
|
+
return Git.open();
|
|
61
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { GithubAuthor } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const USER_AGENT = "nifty-github";
|
|
4
|
+
const ACCEPT = "application/vnd.github+json";
|
|
5
|
+
|
|
6
|
+
function parseUserJson(body: Record<string, unknown>, fallbackLogin?: string): GithubAuthor {
|
|
7
|
+
const id = typeof body.id === "number" ? BigInt(body.id) : undefined;
|
|
8
|
+
const login = typeof body.login === "string" ? body.login : fallbackLogin;
|
|
9
|
+
if (id === undefined && login === undefined) {
|
|
10
|
+
throw new Error("GitHub user response missing id and login");
|
|
11
|
+
}
|
|
12
|
+
return { id, login };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function getJson(url: string, token?: string): Promise<Record<string, unknown>> {
|
|
16
|
+
const headers: Record<string, string> = {
|
|
17
|
+
Accept: ACCEPT,
|
|
18
|
+
"User-Agent": USER_AGENT,
|
|
19
|
+
};
|
|
20
|
+
if (token?.trim()) {
|
|
21
|
+
headers.Authorization = `Bearer ${token.trim()}`;
|
|
22
|
+
}
|
|
23
|
+
const response = await fetch(url, { headers });
|
|
24
|
+
if (response.status === 404) {
|
|
25
|
+
throw new Error("GitHub resource not found");
|
|
26
|
+
}
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
29
|
+
}
|
|
30
|
+
return (await response.json()) as Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseNoreplyEmail(email: string): GithubAuthor | undefined {
|
|
34
|
+
const trimmed = email.trim();
|
|
35
|
+
const suffix = "@users.noreply.github.com";
|
|
36
|
+
const lower = trimmed.toLowerCase();
|
|
37
|
+
if (!lower.endsWith(suffix)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const local = trimmed.slice(0, -suffix.length);
|
|
41
|
+
const plus = local.indexOf("+");
|
|
42
|
+
if (plus >= 0) {
|
|
43
|
+
const idPart = local.slice(0, plus);
|
|
44
|
+
const login = local.slice(plus + 1);
|
|
45
|
+
if (!login) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return { id: BigInt(idPart), login };
|
|
49
|
+
}
|
|
50
|
+
if (!local) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return { login: local };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadAuthorMap(json: string): Map<string, GithubAuthor> {
|
|
57
|
+
const map = new Map<string, GithubAuthor>();
|
|
58
|
+
if (!json.trim()) {
|
|
59
|
+
return map;
|
|
60
|
+
}
|
|
61
|
+
const parsed = JSON.parse(json) as Record<string, { id?: number; login?: string }>;
|
|
62
|
+
for (const [email, entry] of Object.entries(parsed)) {
|
|
63
|
+
const author: GithubAuthor = {};
|
|
64
|
+
if (typeof entry.id === "number") {
|
|
65
|
+
author.id = BigInt(entry.id);
|
|
66
|
+
}
|
|
67
|
+
if (typeof entry.login === "string" && entry.login.trim()) {
|
|
68
|
+
author.login = entry.login.trim();
|
|
69
|
+
}
|
|
70
|
+
if (author.id !== undefined || author.login !== undefined) {
|
|
71
|
+
map.set(email.trim().toLowerCase(), author);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mergeAuthors(existing: GithubAuthor, incoming: GithubAuthor): GithubAuthor {
|
|
78
|
+
return {
|
|
79
|
+
id: existing.id ?? incoming.id,
|
|
80
|
+
login: existing.login ?? incoming.login,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function enrichAuthor(author: GithubAuthor, token?: string, fetch = false): Promise<GithubAuthor> {
|
|
85
|
+
if (!fetch && author.id !== undefined && author.login !== undefined) {
|
|
86
|
+
return author;
|
|
87
|
+
}
|
|
88
|
+
if (!fetch && author.id !== undefined) {
|
|
89
|
+
return author;
|
|
90
|
+
}
|
|
91
|
+
if (author.login && (author.id === undefined || fetch)) {
|
|
92
|
+
const fetched = await userByLoginFetch(author.login, token);
|
|
93
|
+
return mergeAuthors(author, fetched);
|
|
94
|
+
}
|
|
95
|
+
return author;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function userByLoginFetch(login: string, token?: string): Promise<GithubAuthor> {
|
|
99
|
+
const trimmed = login.trim();
|
|
100
|
+
if (!trimmed) {
|
|
101
|
+
throw new Error("login must not be empty");
|
|
102
|
+
}
|
|
103
|
+
const body = await getJson(`https://api.github.com/users/${trimmed}`, token);
|
|
104
|
+
return parseUserJson(body, trimmed);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function searchUserByEmailFetch(email: string, token: string): Promise<GithubAuthor | undefined> {
|
|
108
|
+
const trimmed = email.trim();
|
|
109
|
+
const auth = token.trim();
|
|
110
|
+
if (!trimmed) {
|
|
111
|
+
throw new Error("email must not be empty");
|
|
112
|
+
}
|
|
113
|
+
if (!auth) {
|
|
114
|
+
throw new Error("GitHub token is required for email search");
|
|
115
|
+
}
|
|
116
|
+
const query = encodeURIComponent(`${trimmed} in:email`);
|
|
117
|
+
const body = await getJson(`https://api.github.com/search/users?q=${query}`, auth);
|
|
118
|
+
const items = body.items;
|
|
119
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const first = items[0] as Record<string, unknown>;
|
|
123
|
+
return parseUserJson(first);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type LookupUserOptions = {
|
|
127
|
+
authorMapJson?: string;
|
|
128
|
+
token?: string;
|
|
129
|
+
fetch?: boolean;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* GitHub REST helpers via host `fetch` (Node 18+, browsers with CORS).
|
|
134
|
+
* Rust equivalent: `nifty-github` crate.
|
|
135
|
+
*/
|
|
136
|
+
export class Github {
|
|
137
|
+
constructor(private readonly token?: string) {}
|
|
138
|
+
|
|
139
|
+
static open(token?: string): Github {
|
|
140
|
+
return new Github(token);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async userByLogin(login: string, token?: string): Promise<GithubAuthor> {
|
|
144
|
+
return userByLoginFetch(login, token ?? this.token);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async searchUserByEmail(email: string, token?: string): Promise<GithubAuthor | undefined> {
|
|
148
|
+
const auth = token ?? this.token;
|
|
149
|
+
if (!auth) {
|
|
150
|
+
throw new Error("GitHub token is required for email search");
|
|
151
|
+
}
|
|
152
|
+
return searchUserByEmailFetch(email, auth);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async lookupUserByEmail(email: string, options: LookupUserOptions = {}): Promise<GithubAuthor | undefined> {
|
|
156
|
+
const token = options.token ?? this.token;
|
|
157
|
+
const fetch = options.fetch ?? false;
|
|
158
|
+
const mapJson = options.authorMapJson ?? "{}";
|
|
159
|
+
|
|
160
|
+
const fromNoreply = parseNoreplyEmail(email);
|
|
161
|
+
if (fromNoreply) {
|
|
162
|
+
return enrichAuthor(fromNoreply, token, fetch);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const map = loadAuthorMap(mapJson);
|
|
166
|
+
const mapped = map.get(email.trim().toLowerCase());
|
|
167
|
+
if (mapped) {
|
|
168
|
+
return enrichAuthor(mapped, token, fetch);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (token) {
|
|
172
|
+
const found = await searchUserByEmailFetch(email, token);
|
|
173
|
+
if (found) {
|
|
174
|
+
return enrichAuthor(found, token, fetch);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createGithub(token?: string): Github {
|
|
183
|
+
return Github.open(token);
|
|
184
|
+
}
|