hyouji 0.0.6 → 0.0.7

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.
Files changed (3) hide show
  1. package/README.md +3 -0
  2. package/dist/index.js +268 -65
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -77,6 +77,9 @@ hyouji
77
77
  On your first run, you'll be prompted to enter:
78
78
 
79
79
  - **GitHub Personal Token** - Generate one [here](https://github.com/settings/tokens) with `repo` scope
80
+ <img width="792" height="564" alt="github_token" src="https://github.com/user-attachments/assets/e460738f-833a-4158-a8ba-61752beaad72" />
81
+
82
+
80
83
  - **GitHub Username** - Your GitHub account name
81
84
 
82
85
  These credentials will be securely saved and reused for future sessions.
package/dist/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { Octokit } from "@octokit/core";
3
2
  import chalk from "chalk";
4
- import prompts from "prompts";
5
3
  import { renderFilled } from "oh-my-logo";
6
4
  import * as fs from "fs";
7
5
  import { promises, existsSync } from "fs";
8
6
  import { homedir } from "os";
9
- import { join } from "path";
7
+ import { join, dirname } from "path";
10
8
  import { createHash, randomBytes, createCipheriv, createDecipheriv } from "crypto";
9
+ import prompts from "prompts";
10
+ import { Octokit } from "@octokit/core";
11
+ import { exec } from "child_process";
12
+ import { promisify } from "util";
11
13
  const githubConfigs = [
12
14
  {
13
15
  type: "password",
@@ -1109,6 +1111,177 @@ const getTargetLabel = async () => {
1109
1111
  const response = await prompts(deleteLabel$1);
1110
1112
  return [response.name];
1111
1113
  };
1114
+ const execAsync = promisify(exec);
1115
+ const GIT_COMMAND_TIMEOUT_MS = 5e3;
1116
+ class GitRepositoryDetector {
1117
+ /**
1118
+ * Detects Git repository information from the current working directory
1119
+ * @param cwd - Current working directory (defaults to process.cwd())
1120
+ * @returns Promise<GitDetectionResult>
1121
+ */
1122
+ static async detectRepository(cwd) {
1123
+ const workingDir = cwd || process.cwd();
1124
+ try {
1125
+ const gitRoot = await this.findGitRoot(workingDir);
1126
+ if (!gitRoot) {
1127
+ return {
1128
+ isGitRepository: false,
1129
+ error: "Not a Git repository"
1130
+ };
1131
+ }
1132
+ const remotes = await this.getAllRemotes(gitRoot);
1133
+ if (remotes.length === 0) {
1134
+ return {
1135
+ isGitRepository: true,
1136
+ error: "No remotes configured"
1137
+ };
1138
+ }
1139
+ let remoteUrl = null;
1140
+ let detectionMethod = "origin";
1141
+ if (remotes.includes("origin")) {
1142
+ remoteUrl = await this.getRemoteUrl(gitRoot, "origin");
1143
+ }
1144
+ if (!remoteUrl && remotes.length > 0) {
1145
+ remoteUrl = await this.getRemoteUrl(gitRoot, remotes[0]);
1146
+ detectionMethod = "first-remote";
1147
+ }
1148
+ if (!remoteUrl) {
1149
+ return {
1150
+ isGitRepository: true,
1151
+ error: "Could not retrieve remote URL"
1152
+ };
1153
+ }
1154
+ const parsedUrl = this.parseGitUrl(remoteUrl);
1155
+ if (!parsedUrl) {
1156
+ return {
1157
+ isGitRepository: true,
1158
+ error: "Could not parse remote URL"
1159
+ };
1160
+ }
1161
+ return {
1162
+ isGitRepository: true,
1163
+ repositoryInfo: {
1164
+ owner: parsedUrl.owner,
1165
+ repo: parsedUrl.repo,
1166
+ remoteUrl,
1167
+ detectionMethod
1168
+ }
1169
+ };
1170
+ } catch (err) {
1171
+ return {
1172
+ isGitRepository: false,
1173
+ error: err instanceof Error ? err.message : "Unknown error occurred"
1174
+ };
1175
+ }
1176
+ }
1177
+ /**
1178
+ * Finds the Git root directory by traversing up the directory tree
1179
+ * @param startPath - Starting directory path
1180
+ * @returns Promise<string | null> - Git root path or null if not found
1181
+ */
1182
+ static async findGitRoot(startPath) {
1183
+ let currentPath = startPath;
1184
+ while (currentPath !== dirname(currentPath)) {
1185
+ const gitPath = join(currentPath, ".git");
1186
+ if (existsSync(gitPath)) {
1187
+ return currentPath;
1188
+ }
1189
+ currentPath = dirname(currentPath);
1190
+ }
1191
+ return null;
1192
+ }
1193
+ /**
1194
+ * Gets the URL for a specific Git remote
1195
+ * @param gitRoot - Git repository root directory
1196
+ * @param remoteName - Name of the remote (e.g., 'origin')
1197
+ * @returns Promise<string | null> - Remote URL or null if not found
1198
+ */
1199
+ static async getRemoteUrl(gitRoot, remoteName) {
1200
+ try {
1201
+ const { stdout } = await execAsync(`git remote get-url ${remoteName}`, {
1202
+ cwd: gitRoot,
1203
+ timeout: GIT_COMMAND_TIMEOUT_MS
1204
+ });
1205
+ return stdout.trim() || null;
1206
+ } catch {
1207
+ return null;
1208
+ }
1209
+ }
1210
+ /**
1211
+ * Parses a Git URL to extract owner and repository name
1212
+ * @param url - Git remote URL
1213
+ * @returns Object with owner and repo or null if parsing fails
1214
+ */
1215
+ static parseGitUrl(url) {
1216
+ if (!url || typeof url !== "string" || url.trim().length === 0) {
1217
+ return null;
1218
+ }
1219
+ const trimmedUrl = url.trim();
1220
+ try {
1221
+ const sshMatch = trimmedUrl.match(
1222
+ /^git@github\.com:([^/\s:]+)\/([^/\s:]+?)(?:\.git)?$/
1223
+ );
1224
+ if (sshMatch) {
1225
+ const owner = sshMatch[1];
1226
+ const repo = sshMatch[2];
1227
+ if (this.isValidGitHubIdentifier(owner) && this.isValidGitHubIdentifier(repo)) {
1228
+ return { owner, repo };
1229
+ }
1230
+ }
1231
+ const httpsMatch = trimmedUrl.match(
1232
+ /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/)?$/
1233
+ );
1234
+ if (httpsMatch) {
1235
+ const owner = httpsMatch[1];
1236
+ const repo = httpsMatch[2];
1237
+ if (this.isValidGitHubIdentifier(owner) && this.isValidGitHubIdentifier(repo)) {
1238
+ return { owner, repo };
1239
+ }
1240
+ }
1241
+ const httpMatch = trimmedUrl.match(
1242
+ /^http:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/)?$/
1243
+ );
1244
+ if (httpMatch) {
1245
+ const owner = httpMatch[1];
1246
+ const repo = httpMatch[2];
1247
+ if (this.isValidGitHubIdentifier(owner) && this.isValidGitHubIdentifier(repo)) {
1248
+ return { owner, repo };
1249
+ }
1250
+ }
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ return null;
1255
+ }
1256
+ /**
1257
+ * Validates if a string is a valid GitHub identifier (username or repository name)
1258
+ * @param identifier - The identifier to validate
1259
+ * @returns boolean - True if valid, false otherwise
1260
+ */
1261
+ static isValidGitHubIdentifier(identifier) {
1262
+ if (!identifier || typeof identifier !== "string") {
1263
+ return false;
1264
+ }
1265
+ const GITHUB_IDENTIFIER_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
1266
+ return identifier.length >= 1 && identifier.length <= 39 && GITHUB_IDENTIFIER_REGEX.test(identifier) && !identifier.includes("--");
1267
+ }
1268
+ /**
1269
+ * Gets all configured Git remotes
1270
+ * @param gitRoot - Git repository root directory
1271
+ * @returns Promise<string[]> - Array of remote names
1272
+ */
1273
+ static async getAllRemotes(gitRoot) {
1274
+ try {
1275
+ const { stdout } = await execAsync("git remote", {
1276
+ cwd: gitRoot,
1277
+ timeout: GIT_COMMAND_TIMEOUT_MS
1278
+ });
1279
+ return stdout.trim().split("\n").filter((remote) => remote.length > 0);
1280
+ } catch {
1281
+ return [];
1282
+ }
1283
+ }
1284
+ }
1112
1285
  const getGitHubConfigs = async () => {
1113
1286
  var _a, _b;
1114
1287
  const configManager2 = new ConfigManager();
@@ -1130,6 +1303,50 @@ const getGitHubConfigs = async () => {
1130
1303
  };
1131
1304
  }
1132
1305
  if (validationResult.config && !validationResult.shouldPromptForCredentials) {
1306
+ try {
1307
+ const detectionResult = await GitRepositoryDetector.detectRepository();
1308
+ if (detectionResult.isGitRepository && detectionResult.repositoryInfo) {
1309
+ console.log(
1310
+ chalk.green(
1311
+ `✓ Detected repository: ${detectionResult.repositoryInfo.owner}/${detectionResult.repositoryInfo.repo}`
1312
+ )
1313
+ );
1314
+ console.log(
1315
+ chalk.gray(
1316
+ ` Detection method: ${detectionResult.repositoryInfo.detectionMethod === "origin" ? "origin remote" : "first available remote"}`
1317
+ )
1318
+ );
1319
+ const octokit3 = new Octokit({
1320
+ auth: validationResult.config.token
1321
+ });
1322
+ return {
1323
+ octokit: octokit3,
1324
+ owner: detectionResult.repositoryInfo.owner,
1325
+ repo: detectionResult.repositoryInfo.repo,
1326
+ fromSavedConfig: true,
1327
+ autoDetected: true,
1328
+ detectionMethod: detectionResult.repositoryInfo.detectionMethod
1329
+ };
1330
+ } else {
1331
+ if (detectionResult.error) {
1332
+ console.log(
1333
+ chalk.yellow(
1334
+ `⚠️ Repository auto-detection failed: ${detectionResult.error}`
1335
+ )
1336
+ );
1337
+ }
1338
+ console.log(chalk.gray(" Falling back to manual input..."));
1339
+ }
1340
+ } catch (error) {
1341
+ console.log(
1342
+ chalk.yellow(
1343
+ "⚠️ Repository auto-detection failed, falling back to manual input"
1344
+ )
1345
+ );
1346
+ if (error instanceof Error) {
1347
+ console.log(chalk.gray(` Error: ${error.message}`));
1348
+ }
1349
+ }
1133
1350
  const repoResponse = await prompts([
1134
1351
  {
1135
1352
  type: "text",
@@ -1144,7 +1361,9 @@ const getGitHubConfigs = async () => {
1144
1361
  octokit: octokit2,
1145
1362
  owner: validationResult.config.owner,
1146
1363
  repo: repoResponse.repo,
1147
- fromSavedConfig: true
1364
+ fromSavedConfig: true,
1365
+ autoDetected: false,
1366
+ detectionMethod: "manual"
1148
1367
  };
1149
1368
  }
1150
1369
  const promptConfig = [...githubConfigs];
@@ -1196,7 +1415,9 @@ const getGitHubConfigs = async () => {
1196
1415
  octokit,
1197
1416
  owner: response.owner,
1198
1417
  repo: response.repo,
1199
- fromSavedConfig: false
1418
+ fromSavedConfig: false,
1419
+ autoDetected: false,
1420
+ detectionMethod: "manual"
1200
1421
  };
1201
1422
  };
1202
1423
  const getJsonFilePath = async () => {
@@ -1215,33 +1436,6 @@ const selectAction = async () => {
1215
1436
  const log = console.log;
1216
1437
  let firstStart = true;
1217
1438
  const configManager = new ConfigManager();
1218
- const setupConfigs = async () => {
1219
- console.log(initialText);
1220
- if (firstStart) {
1221
- await configManager.migrateToEncrypted();
1222
- }
1223
- const config = await getGitHubConfigs();
1224
- if (!config.octokit || !config.owner || !config.repo) {
1225
- throw new Error("Invalid configuration: missing required fields");
1226
- }
1227
- try {
1228
- await config.octokit.request("GET /user");
1229
- } catch (error) {
1230
- if (config.fromSavedConfig) {
1231
- console.log(
1232
- chalk.yellow(
1233
- "Saved credentials are invalid. Please provide new credentials."
1234
- )
1235
- );
1236
- await configManager.clearConfig();
1237
- return setupConfigs();
1238
- }
1239
- throw new Error(
1240
- `GitHub API authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`
1241
- );
1242
- }
1243
- return config;
1244
- };
1245
1439
  const displaySettings = async () => {
1246
1440
  log(chalk.cyan("\n=== Current Settings ==="));
1247
1441
  const configPath = configManager.getConfigPath();
@@ -1318,47 +1512,56 @@ const initializeConfigs = async () => {
1318
1512
  console.warn("Failed to display ASCII art, continuing...");
1319
1513
  console.error("Error:", error);
1320
1514
  }
1321
- if (hasValidConfig) {
1322
- try {
1323
- const existingConfig = await configManager.loadValidatedConfig();
1324
- if (existingConfig && existingConfig.config) {
1325
- const repoResponse = await prompts([
1326
- {
1327
- type: "text",
1328
- name: "repo",
1329
- message: "Please type your target repo name"
1330
- }
1331
- ]);
1332
- const config = {
1333
- octokit: new Octokit({ auth: existingConfig.config.token }),
1334
- owner: existingConfig.config.owner,
1335
- repo: repoResponse.repo,
1336
- fromSavedConfig: true
1337
- };
1338
- log(chalk.green(`Using saved configuration for ${config.owner}`));
1339
- return config;
1340
- } else {
1341
- return await setupConfigs();
1342
- }
1343
- } catch (error) {
1344
- console.error("Error:", error);
1345
- return await setupConfigs();
1515
+ try {
1516
+ console.log(initialText);
1517
+ if (firstStart) {
1518
+ await configManager.migrateToEncrypted();
1519
+ }
1520
+ const config = await getGitHubConfigs();
1521
+ if (!config.octokit || !config.owner || !config.repo) {
1522
+ throw new Error("Invalid configuration: missing required fields");
1346
1523
  }
1347
- } else {
1348
1524
  try {
1349
- const config = await setupConfigs();
1525
+ await config.octokit.request("GET /user");
1526
+ } catch (error) {
1350
1527
  if (config.fromSavedConfig) {
1351
- log(chalk.green(`Using saved configuration for ${config.owner}`));
1528
+ console.log(
1529
+ chalk.yellow(
1530
+ "Saved credentials are invalid. Please provide new credentials."
1531
+ )
1532
+ );
1533
+ await configManager.clearConfig();
1534
+ return initializeConfigs();
1352
1535
  }
1353
- return config;
1354
- } catch (error) {
1536
+ throw new Error(
1537
+ `GitHub API authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`
1538
+ );
1539
+ }
1540
+ if (config.fromSavedConfig) {
1541
+ log(chalk.green(`✓ Using saved configuration for ${config.owner}`));
1542
+ }
1543
+ if (config.autoDetected) {
1355
1544
  log(
1356
- chalk.red(
1357
- `Configuration error: ${error instanceof Error ? error.message : "Unknown error"}`
1545
+ chalk.green(
1546
+ `✓ Repository auto-detected: ${config.owner}/${config.repo}`
1358
1547
  )
1359
1548
  );
1360
- return null;
1549
+ const detectionMethodText = config.detectionMethod === "origin" ? "origin remote" : config.detectionMethod === "first-remote" ? "first available remote" : "manual input";
1550
+ log(chalk.gray(` Detection method: ${detectionMethodText}`));
1551
+ } else if (config.detectionMethod === "manual") {
1552
+ log(
1553
+ chalk.blue(`✓ Repository configured: ${config.owner}/${config.repo}`)
1554
+ );
1555
+ log(chalk.gray(` Input method: manual`));
1361
1556
  }
1557
+ return config;
1558
+ } catch (error) {
1559
+ log(
1560
+ chalk.red(
1561
+ `Configuration error: ${error instanceof Error ? error.message : "Unknown error"}`
1562
+ )
1563
+ );
1564
+ return null;
1362
1565
  }
1363
1566
  };
1364
1567
  const main = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyouji",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Hyouji (表示) — A command-line tool for organizing and displaying GitHub labels with clarity and harmony.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "test:error-handling": "node tests/scripts/error-handling/run-all.cjs",
39
39
  "test:config": "node tests/scripts/config/run-all.cjs",
40
40
  "test:integration": "node tests/scripts/run-integration.cjs",
41
+ "test:auto-detection": "node tests/integration/auto-detection/integration-flow.cjs",
41
42
  "test:verification": "node tests/scripts/verification/run-all.cjs",
42
43
  "test:all-custom": "npm run test:error-handling && npm run test:config && npm run test:integration && npm run test:verification",
43
44
  "check-cli": "run-s test diff-integration-tests check-integration-tests",