appmaker-git-remote-origin-fixer 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 +99 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +74 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +73 -0
- package/dist/git.d.ts +27 -0
- package/dist/git.js +89 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +75 -0
- package/dist/remote-url.d.ts +28 -0
- package/dist/remote-url.js +44 -0
- package/dist/ui.d.ts +25 -0
- package/dist/ui.js +83 -0
- package/migrations.json +7 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# appmaker-git-remote-origin-fixer
|
|
2
|
+
|
|
3
|
+
A small recovery CLI that repairs a local git `origin` remote after a GitHub
|
|
4
|
+
**organization migration**.
|
|
5
|
+
|
|
6
|
+
When repositories are transferred from one GitHub organization to another and
|
|
7
|
+
then deleted from the old one, every developer's local clone still points at the
|
|
8
|
+
old remote — so `git push` fails. Run this tool once, after that first failed
|
|
9
|
+
push, and it repoints `origin` at the new organization for you.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
git push # ❌ fails — repo no longer exists in the old org
|
|
13
|
+
appmaker-git-remote-origin-fixer # 🔧 diagnoses and fixes origin
|
|
14
|
+
git push # ✅ works
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g appmaker-git-remote-origin-fixer
|
|
21
|
+
# or run without installing:
|
|
22
|
+
npx appmaker-git-remote-origin-fixer
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Run it from inside the affected repository:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
appmaker-git-remote-origin-fixer [options]
|
|
31
|
+
|
|
32
|
+
-c, --config <path> Use a specific migrations config file
|
|
33
|
+
-n, --dry-run Diagnose only; do not modify the remote
|
|
34
|
+
-h, --help Show help
|
|
35
|
+
-v, --version Show version
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Read origin URL → parse org + repo
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
Is the org a "source" in the migration config?
|
|
45
|
+
│ no → "No migration configured" → exit
|
|
46
|
+
▼ yes
|
|
47
|
+
Is the repo still reachable in the SOURCE org? (git ls-remote)
|
|
48
|
+
├─ reachable → "still exists, nothing to do"
|
|
49
|
+
├─ no permission → permission error
|
|
50
|
+
└─ not found ↓
|
|
51
|
+
Is the repo reachable in the DESTINATION org? (git ls-remote)
|
|
52
|
+
├─ reachable → rewrite origin → "run git push"
|
|
53
|
+
├─ no permission → "exists, contact admin" (remote left unchanged)
|
|
54
|
+
└─ not found → "not found in either org"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Repository existence is verified with `git ls-remote` using your **existing git
|
|
58
|
+
credentials** — no GitHub token or API access is required. The remote is only
|
|
59
|
+
rewritten once the destination is confirmed reachable.
|
|
60
|
+
|
|
61
|
+
> **Note:** verification is most reliable with **SSH remotes**
|
|
62
|
+
> (`git@github.com:Org/repo.git`), where GitHub returns a clear
|
|
63
|
+
> "Repository not found" for missing repos. Over HTTPS without cached
|
|
64
|
+
> credentials, git may be unable to determine existence; in that case the tool
|
|
65
|
+
> reports that it could not verify and leaves your remote untouched.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Migrations are data-driven — a simple map of `source-org → destination-org`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"migrations": {
|
|
74
|
+
"Appmaker-HQ": "Appmaker-xyz",
|
|
75
|
+
"Appmaker-Partners": "Appmaker-Partners-HQ"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The config is resolved in this order (first found wins):
|
|
81
|
+
|
|
82
|
+
1. `--config <path>`
|
|
83
|
+
2. `./migrations.json` (current directory)
|
|
84
|
+
3. `~/.appmaker-git-remote-origin-fixer.json` (per-user override)
|
|
85
|
+
4. the `migrations.json` bundled with the package
|
|
86
|
+
|
|
87
|
+
To support a new migration, update the config — no code change needed.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install
|
|
93
|
+
npm run build # compile TypeScript -> dist/
|
|
94
|
+
npm start # run dist/cli.js
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point: parse arguments, run the fixer, set the exit code.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { run } from "./index.js";
|
|
10
|
+
function readVersion() {
|
|
11
|
+
try {
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const pkgPath = resolve(here, "..", "package.json");
|
|
14
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
15
|
+
return pkg.version ?? "unknown";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "unknown";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const VERSION = readVersion();
|
|
22
|
+
const HELP = `appmaker-git-remote-origin-fixer
|
|
23
|
+
|
|
24
|
+
Repairs a local git "origin" remote after a GitHub organization migration.
|
|
25
|
+
Run it from inside your repository after a push fails.
|
|
26
|
+
|
|
27
|
+
USAGE
|
|
28
|
+
appmaker-git-remote-origin-fixer [options]
|
|
29
|
+
|
|
30
|
+
OPTIONS
|
|
31
|
+
-c, --config <path> Use a specific migrations config file.
|
|
32
|
+
-n, --dry-run Diagnose only; do not modify the remote.
|
|
33
|
+
-h, --help Show this help.
|
|
34
|
+
-v, --version Show the version.
|
|
35
|
+
`;
|
|
36
|
+
function main() {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = parseArgs({
|
|
40
|
+
options: {
|
|
41
|
+
config: { type: "string", short: "c" },
|
|
42
|
+
"dry-run": { type: "boolean", short: "n" },
|
|
43
|
+
help: { type: "boolean", short: "h" },
|
|
44
|
+
version: { type: "boolean", short: "v" },
|
|
45
|
+
},
|
|
46
|
+
allowPositionals: false,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
console.error(err.message);
|
|
51
|
+
console.error(`\nRun with --help for usage.`);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
const { values } = parsed;
|
|
55
|
+
if (values.help) {
|
|
56
|
+
console.log(HELP);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (values.version) {
|
|
60
|
+
console.log(VERSION);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return run({
|
|
65
|
+
configPath: values.config,
|
|
66
|
+
dryRun: values["dry-run"],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error(`✖ ${err.message}`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
process.exit(main());
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading and validation of the migration configuration.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (first found wins):
|
|
5
|
+
* 1. --config <path> (explicit flag)
|
|
6
|
+
* 2. ./migrations.json (current working directory)
|
|
7
|
+
* 3. ~/.appmaker-git-remote-origin-fixer.json (per-user home override)
|
|
8
|
+
* 4. bundled migrations.json (shipped with the package)
|
|
9
|
+
*/
|
|
10
|
+
export interface MigrationConfig {
|
|
11
|
+
/** Map of source organization -> destination organization. */
|
|
12
|
+
migrations: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
export interface LoadedConfig extends MigrationConfig {
|
|
15
|
+
/** Absolute path the config was loaded from (for diagnostics). */
|
|
16
|
+
source: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Load the first available config file. Throws only on a malformed file that
|
|
20
|
+
* exists; missing files are skipped. The bundled config always exists, so this
|
|
21
|
+
* resolves unless the package install is broken.
|
|
22
|
+
*/
|
|
23
|
+
export declare function loadConfig(explicit?: string): LoadedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading and validation of the migration configuration.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (first found wins):
|
|
5
|
+
* 1. --config <path> (explicit flag)
|
|
6
|
+
* 2. ./migrations.json (current working directory)
|
|
7
|
+
* 3. ~/.appmaker-git-remote-origin-fixer.json (per-user home override)
|
|
8
|
+
* 4. bundled migrations.json (shipped with the package)
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, dirname, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
// dist/config.js -> package root
|
|
16
|
+
const BUNDLED = resolve(here, "..", "migrations.json");
|
|
17
|
+
function candidatePaths(explicit) {
|
|
18
|
+
const paths = [];
|
|
19
|
+
if (explicit)
|
|
20
|
+
paths.push(resolve(explicit));
|
|
21
|
+
paths.push(resolve(process.cwd(), "migrations.json"));
|
|
22
|
+
paths.push(join(homedir(), ".appmaker-git-remote-origin-fixer.json"));
|
|
23
|
+
paths.push(BUNDLED);
|
|
24
|
+
return paths;
|
|
25
|
+
}
|
|
26
|
+
function parseConfig(text, path) {
|
|
27
|
+
let data;
|
|
28
|
+
try {
|
|
29
|
+
data = JSON.parse(text);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error(`Config file is not valid JSON: ${path}`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof data !== "object" ||
|
|
35
|
+
data === null ||
|
|
36
|
+
typeof data.migrations !== "object" ||
|
|
37
|
+
data.migrations === null) {
|
|
38
|
+
throw new Error(`Config file must contain a "migrations" object: ${path}`);
|
|
39
|
+
}
|
|
40
|
+
const migrations = data.migrations;
|
|
41
|
+
for (const [from, to] of Object.entries(migrations)) {
|
|
42
|
+
if (typeof to !== "string" || to.length === 0) {
|
|
43
|
+
throw new Error(`Migration destination for "${from}" must be a non-empty string: ${path}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { migrations };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Load the first available config file. Throws only on a malformed file that
|
|
50
|
+
* exists; missing files are skipped. The bundled config always exists, so this
|
|
51
|
+
* resolves unless the package install is broken.
|
|
52
|
+
*/
|
|
53
|
+
export function loadConfig(explicit) {
|
|
54
|
+
const paths = candidatePaths(explicit);
|
|
55
|
+
for (const path of paths) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = readFileSync(path, "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Not found / unreadable -> try the next candidate.
|
|
62
|
+
// An explicit --config that is missing is a hard error, though.
|
|
63
|
+
if (explicit && path === resolve(explicit)) {
|
|
64
|
+
throw new Error(`Config file not found: ${path}`);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const config = parseConfig(text, path);
|
|
69
|
+
return { ...config, source: path };
|
|
70
|
+
}
|
|
71
|
+
// Should be unreachable because BUNDLED ships with the package.
|
|
72
|
+
throw new Error("No migration configuration could be loaded.");
|
|
73
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrappers around the local `git` executable.
|
|
3
|
+
*/
|
|
4
|
+
export type RemoteStatus = "reachable" | "not_found" | "forbidden" | "unknown";
|
|
5
|
+
export interface ProbeResult {
|
|
6
|
+
status: RemoteStatus;
|
|
7
|
+
/** Raw stderr from git, for diagnostics on "unknown". */
|
|
8
|
+
detail: string;
|
|
9
|
+
}
|
|
10
|
+
/** True if the current directory is inside a git work tree. */
|
|
11
|
+
export declare function isGitRepo(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Read the raw URL of the `origin` remote, or null if it has none.
|
|
14
|
+
*
|
|
15
|
+
* Uses `git config --get` rather than `git remote get-url` so the value is the
|
|
16
|
+
* one literally stored in config — not an `insteadOf`-rewritten variant. This
|
|
17
|
+
* is the value we parse and later overwrite with `set-url`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getOriginUrl(): string | null;
|
|
20
|
+
/** Point the `origin` remote at a new URL. */
|
|
21
|
+
export declare function setOriginUrl(url: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Probe whether a remote URL is reachable using `git ls-remote`, relying on the
|
|
24
|
+
* developer's existing SSH/HTTPS credentials. Classifies the failure so callers
|
|
25
|
+
* can distinguish "missing" from "no permission".
|
|
26
|
+
*/
|
|
27
|
+
export declare function probeRemote(url: string): ProbeResult;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrappers around the local `git` executable.
|
|
3
|
+
*/
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
function runGit(args) {
|
|
6
|
+
try {
|
|
7
|
+
const stdout = execFileSync("git", args, {
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
return { ok: true, stdout: stdout.trim(), stderr: "" };
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const e = err;
|
|
15
|
+
return {
|
|
16
|
+
ok: false,
|
|
17
|
+
stdout: e.stdout?.toString().trim() ?? "",
|
|
18
|
+
stderr: e.stderr?.toString().trim() ?? "",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** True if the current directory is inside a git work tree. */
|
|
23
|
+
export function isGitRepo() {
|
|
24
|
+
return runGit(["rev-parse", "--is-inside-work-tree"]).ok;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Read the raw URL of the `origin` remote, or null if it has none.
|
|
28
|
+
*
|
|
29
|
+
* Uses `git config --get` rather than `git remote get-url` so the value is the
|
|
30
|
+
* one literally stored in config — not an `insteadOf`-rewritten variant. This
|
|
31
|
+
* is the value we parse and later overwrite with `set-url`.
|
|
32
|
+
*/
|
|
33
|
+
export function getOriginUrl() {
|
|
34
|
+
const result = runGit(["config", "--get", "remote.origin.url"]);
|
|
35
|
+
return result.ok && result.stdout ? result.stdout : null;
|
|
36
|
+
}
|
|
37
|
+
/** Point the `origin` remote at a new URL. */
|
|
38
|
+
export function setOriginUrl(url) {
|
|
39
|
+
const result = runGit(["remote", "set-url", "origin", url]);
|
|
40
|
+
if (!result.ok) {
|
|
41
|
+
throw new Error(`Failed to update origin: ${result.stderr || "unknown error"}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Probe whether a remote URL is reachable using `git ls-remote`, relying on the
|
|
46
|
+
* developer's existing SSH/HTTPS credentials. Classifies the failure so callers
|
|
47
|
+
* can distinguish "missing" from "no permission".
|
|
48
|
+
*/
|
|
49
|
+
export function probeRemote(url) {
|
|
50
|
+
// GIT_TERMINAL_PROMPT=0 prevents git from blocking on a username/password
|
|
51
|
+
// prompt when credentials are missing; it fails fast instead.
|
|
52
|
+
const prevPrompt = process.env.GIT_TERMINAL_PROMPT;
|
|
53
|
+
process.env.GIT_TERMINAL_PROMPT = "0";
|
|
54
|
+
try {
|
|
55
|
+
// No --exit-code: a reachable repo with zero refs still exits 0, which is
|
|
56
|
+
// what we want. Only connection/permission/missing errors are non-zero.
|
|
57
|
+
const result = runGit(["ls-remote", "-h", url]);
|
|
58
|
+
if (result.ok)
|
|
59
|
+
return { status: "reachable", detail: "" };
|
|
60
|
+
const stderr = result.stderr.toLowerCase();
|
|
61
|
+
if (stderr.includes("repository not found") ||
|
|
62
|
+
stderr.includes("not found") ||
|
|
63
|
+
stderr.includes("does not exist") ||
|
|
64
|
+
stderr.includes("could not read from remote repository")) {
|
|
65
|
+
// Note: GitHub returns "Repository not found" for both a missing repo and
|
|
66
|
+
// a private repo you can't see. Permission patterns below take priority.
|
|
67
|
+
if (stderr.includes("permission denied") ||
|
|
68
|
+
stderr.includes("authentication failed") ||
|
|
69
|
+
stderr.includes("access denied") ||
|
|
70
|
+
stderr.includes("403")) {
|
|
71
|
+
return { status: "forbidden", detail: result.stderr };
|
|
72
|
+
}
|
|
73
|
+
return { status: "not_found", detail: result.stderr };
|
|
74
|
+
}
|
|
75
|
+
if (stderr.includes("permission denied") ||
|
|
76
|
+
stderr.includes("authentication failed") ||
|
|
77
|
+
stderr.includes("access denied") ||
|
|
78
|
+
stderr.includes("403")) {
|
|
79
|
+
return { status: "forbidden", detail: result.stderr };
|
|
80
|
+
}
|
|
81
|
+
return { status: "unknown", detail: result.stderr };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
if (prevPrompt === undefined)
|
|
85
|
+
delete process.env.GIT_TERMINAL_PROMPT;
|
|
86
|
+
else
|
|
87
|
+
process.env.GIT_TERMINAL_PROMPT = prevPrompt;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates the diagnose-and-fix flow.
|
|
3
|
+
*
|
|
4
|
+
* 1. Read origin remote
|
|
5
|
+
* 2. Parse org + repo
|
|
6
|
+
* 3. Look up destination org in config
|
|
7
|
+
* 4. Probe source org -> still exists? permission? gone?
|
|
8
|
+
* 5. Probe destination -> found -> rewrite origin; else explain
|
|
9
|
+
*/
|
|
10
|
+
export interface RunOptions {
|
|
11
|
+
/** Explicit config path from --config. */
|
|
12
|
+
configPath?: string;
|
|
13
|
+
/** Diagnose only; never modify the remote. */
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Returns a process exit code. */
|
|
17
|
+
export declare function run(options?: RunOptions): number;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates the diagnose-and-fix flow.
|
|
3
|
+
*
|
|
4
|
+
* 1. Read origin remote
|
|
5
|
+
* 2. Parse org + repo
|
|
6
|
+
* 3. Look up destination org in config
|
|
7
|
+
* 4. Probe source org -> still exists? permission? gone?
|
|
8
|
+
* 5. Probe destination -> found -> rewrite origin; else explain
|
|
9
|
+
*/
|
|
10
|
+
import { loadConfig } from "./config.js";
|
|
11
|
+
import { getOriginUrl, isGitRepo, probeRemote, setOriginUrl, } from "./git.js";
|
|
12
|
+
import { parseRemote, rewriteOrg } from "./remote-url.js";
|
|
13
|
+
import * as ui from "./ui.js";
|
|
14
|
+
/** Returns a process exit code. */
|
|
15
|
+
export function run(options = {}) {
|
|
16
|
+
if (!isGitRepo()) {
|
|
17
|
+
ui.notAGitRepo();
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
const originUrl = getOriginUrl();
|
|
21
|
+
if (!originUrl) {
|
|
22
|
+
ui.noOrigin();
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
const parsed = parseRemote(originUrl);
|
|
26
|
+
if (!parsed) {
|
|
27
|
+
ui.unparseableRemote(originUrl);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
const config = loadConfig(options.configPath);
|
|
31
|
+
const destOrg = config.migrations[parsed.org];
|
|
32
|
+
if (!destOrg) {
|
|
33
|
+
ui.noMigration(parsed.org);
|
|
34
|
+
return 0; // Not an error — just nothing to do.
|
|
35
|
+
}
|
|
36
|
+
// Step 4 — is the repo still in the source org?
|
|
37
|
+
const source = probeRemote(originUrl);
|
|
38
|
+
if (source.status === "reachable") {
|
|
39
|
+
ui.stillExists(parsed.org, parsed.repo);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (source.status === "forbidden") {
|
|
43
|
+
ui.permissionDenied(parsed.org, parsed.repo);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
if (source.status === "unknown") {
|
|
47
|
+
ui.probeFailed(originUrl, source.detail);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
// status === "not_found" -> continue to the destination.
|
|
51
|
+
// Step 5 — is the repo in the destination org?
|
|
52
|
+
const destUrl = rewriteOrg(parsed, destOrg);
|
|
53
|
+
const dest = probeRemote(destUrl);
|
|
54
|
+
if (dest.status === "reachable") {
|
|
55
|
+
if (options.dryRun) {
|
|
56
|
+
ui.info("(dry run) Would update origin:");
|
|
57
|
+
ui.info(` ${originUrl} -> ${destUrl}`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
setOriginUrl(destUrl);
|
|
61
|
+
ui.updated(destOrg, originUrl, destUrl);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
if (dest.status === "forbidden") {
|
|
65
|
+
ui.permissionDenied(destOrg, parsed.repo);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
if (dest.status === "unknown") {
|
|
69
|
+
ui.probeFailed(destUrl, dest.detail);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
// Not found in either org.
|
|
73
|
+
ui.notFoundAnywhere(parsed.repo, [parsed.org, destOrg]);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing and rewriting of git remote URLs.
|
|
3
|
+
*
|
|
4
|
+
* Supports the two forms developers actually use with GitHub:
|
|
5
|
+
* - SCP-like SSH: git@github.com:Org/repo.git
|
|
6
|
+
* - URL form: https://github.com/Org/repo.git
|
|
7
|
+
* ssh://git@github.com/Org/repo.git
|
|
8
|
+
*/
|
|
9
|
+
export interface ParsedRemote {
|
|
10
|
+
/** Original, unmodified URL. */
|
|
11
|
+
raw: string;
|
|
12
|
+
/** Host, e.g. "github.com". */
|
|
13
|
+
host: string;
|
|
14
|
+
/** Owning organization / user. */
|
|
15
|
+
org: string;
|
|
16
|
+
/** Repository name, without the trailing ".git". */
|
|
17
|
+
repo: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a git remote URL into its components, or return null if the URL is not
|
|
21
|
+
* a shape we recognize.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseRemote(url: string): ParsedRemote | null;
|
|
24
|
+
/**
|
|
25
|
+
* Produce a new remote URL identical to `parsed` but pointing at `destOrg`.
|
|
26
|
+
* The original URL format (SSH vs HTTPS, with/without ".git") is preserved.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rewriteOrg(parsed: ParsedRemote, destOrg: string): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing and rewriting of git remote URLs.
|
|
3
|
+
*
|
|
4
|
+
* Supports the two forms developers actually use with GitHub:
|
|
5
|
+
* - SCP-like SSH: git@github.com:Org/repo.git
|
|
6
|
+
* - URL form: https://github.com/Org/repo.git
|
|
7
|
+
* ssh://git@github.com/Org/repo.git
|
|
8
|
+
*/
|
|
9
|
+
// git@github.com:Org/repo(.git)
|
|
10
|
+
const SCP_LIKE = /^(?<user>[^@\s]+@)?(?<host>[^:/\s]+):(?<org>[^/\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/;
|
|
11
|
+
// scheme://[user@]github.com/Org/repo(.git)
|
|
12
|
+
const URL_LIKE = /^(?<scheme>[a-z][a-z0-9+.-]*:\/\/)(?<user>[^@/\s]+@)?(?<host>[^/\s]+)\/(?<org>[^/\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i;
|
|
13
|
+
/**
|
|
14
|
+
* Parse a git remote URL into its components, or return null if the URL is not
|
|
15
|
+
* a shape we recognize.
|
|
16
|
+
*/
|
|
17
|
+
export function parseRemote(url) {
|
|
18
|
+
const trimmed = url.trim();
|
|
19
|
+
const match = trimmed.match(URL_LIKE) ?? trimmed.match(SCP_LIKE);
|
|
20
|
+
if (!match?.groups)
|
|
21
|
+
return null;
|
|
22
|
+
const { host, org, repo } = match.groups;
|
|
23
|
+
if (!host || !org || !repo)
|
|
24
|
+
return null;
|
|
25
|
+
return { raw: trimmed, host, org, repo };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Produce a new remote URL identical to `parsed` but pointing at `destOrg`.
|
|
29
|
+
* The original URL format (SSH vs HTTPS, with/without ".git") is preserved.
|
|
30
|
+
*/
|
|
31
|
+
export function rewriteOrg(parsed, destOrg) {
|
|
32
|
+
// Replace the first "<org>/<repo>" occurrence to preserve the original format.
|
|
33
|
+
const needle = `${parsed.org}/${parsed.repo}`;
|
|
34
|
+
const replacement = `${destOrg}/${parsed.repo}`;
|
|
35
|
+
const idx = parsed.raw.indexOf(needle);
|
|
36
|
+
if (idx === -1) {
|
|
37
|
+
// Should not happen given parsing succeeded, but fail loudly rather than
|
|
38
|
+
// returning a malformed URL.
|
|
39
|
+
throw new Error(`Could not rewrite remote URL: ${parsed.raw}`);
|
|
40
|
+
}
|
|
41
|
+
return (parsed.raw.slice(0, idx) +
|
|
42
|
+
replacement +
|
|
43
|
+
parsed.raw.slice(idx + needle.length));
|
|
44
|
+
}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All user-facing output, centralized so wording is easy to audit and tweak.
|
|
3
|
+
* Colors degrade gracefully when stdout is not a TTY or NO_COLOR is set.
|
|
4
|
+
*/
|
|
5
|
+
export declare function info(message: string): void;
|
|
6
|
+
export declare function detail(message: string): void;
|
|
7
|
+
export declare function error(message: string): void;
|
|
8
|
+
/** Step 0: not a git repo. */
|
|
9
|
+
export declare function notAGitRepo(): void;
|
|
10
|
+
/** Step 0: origin remote missing. */
|
|
11
|
+
export declare function noOrigin(): void;
|
|
12
|
+
/** Step 1: could not parse the remote URL. */
|
|
13
|
+
export declare function unparseableRemote(url: string): void;
|
|
14
|
+
/** Step 2: no migration configured for this org. */
|
|
15
|
+
export declare function noMigration(org: string): void;
|
|
16
|
+
/** Step 3: repository still exists in the source org. */
|
|
17
|
+
export declare function stillExists(org: string, repo: string): void;
|
|
18
|
+
/** Step 3/4: permission denied on a repository. */
|
|
19
|
+
export declare function permissionDenied(org: string, repo: string): void;
|
|
20
|
+
/** Step 4: found in destination -> updated. */
|
|
21
|
+
export declare function updated(destOrg: string, from: string, to: string): void;
|
|
22
|
+
/** Step 4: not found anywhere. */
|
|
23
|
+
export declare function notFoundAnywhere(repo: string, checked: string[]): void;
|
|
24
|
+
/** A git probe returned an unclassifiable error. */
|
|
25
|
+
export declare function probeFailed(url: string, detailText: string): void;
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All user-facing output, centralized so wording is easy to audit and tweak.
|
|
3
|
+
* Colors degrade gracefully when stdout is not a TTY or NO_COLOR is set.
|
|
4
|
+
*/
|
|
5
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
6
|
+
const paint = (code, s) => useColor ? `[${code}m${s}[0m` : s;
|
|
7
|
+
const green = (s) => paint("32", s);
|
|
8
|
+
const red = (s) => paint("31", s);
|
|
9
|
+
const yellow = (s) => paint("33", s);
|
|
10
|
+
const dim = (s) => paint("2", s);
|
|
11
|
+
const bold = (s) => paint("1", s);
|
|
12
|
+
const CHECK = green("✔");
|
|
13
|
+
const CROSS = red("✖");
|
|
14
|
+
export function info(message) {
|
|
15
|
+
console.log(message);
|
|
16
|
+
}
|
|
17
|
+
export function detail(message) {
|
|
18
|
+
console.log(dim(message));
|
|
19
|
+
}
|
|
20
|
+
export function error(message) {
|
|
21
|
+
console.error(`${CROSS} ${red(message)}`);
|
|
22
|
+
}
|
|
23
|
+
/** Step 0: not a git repo. */
|
|
24
|
+
export function notAGitRepo() {
|
|
25
|
+
error("This is not a git repository.");
|
|
26
|
+
console.error(dim("Run this command from inside your project folder."));
|
|
27
|
+
}
|
|
28
|
+
/** Step 0: origin remote missing. */
|
|
29
|
+
export function noOrigin() {
|
|
30
|
+
error("No 'origin' remote is configured.");
|
|
31
|
+
console.error(dim("Nothing to fix — add a remote first with `git remote add origin <url>`."));
|
|
32
|
+
}
|
|
33
|
+
/** Step 1: could not parse the remote URL. */
|
|
34
|
+
export function unparseableRemote(url) {
|
|
35
|
+
error("Could not understand the current origin URL:");
|
|
36
|
+
console.error(` ${url}`);
|
|
37
|
+
console.error(dim("Only github.com SSH and HTTPS URLs are supported."));
|
|
38
|
+
}
|
|
39
|
+
/** Step 2: no migration configured for this org. */
|
|
40
|
+
export function noMigration(org) {
|
|
41
|
+
info(`No migration configured for organization:\n\n ${bold(org)}`);
|
|
42
|
+
}
|
|
43
|
+
/** Step 3: repository still exists in the source org. */
|
|
44
|
+
export function stillExists(org, repo) {
|
|
45
|
+
info(`${CHECK} Repository ${bold(`${org}/${repo}`)} still exists.`);
|
|
46
|
+
info("No changes required.");
|
|
47
|
+
}
|
|
48
|
+
/** Step 3/4: permission denied on a repository. */
|
|
49
|
+
export function permissionDenied(org, repo) {
|
|
50
|
+
info(`Repository ${bold(`${org}/${repo}`)} exists.`);
|
|
51
|
+
info(yellow("You do not have permission to access this repository."));
|
|
52
|
+
info("Please contact your administrator.");
|
|
53
|
+
}
|
|
54
|
+
/** Step 4: found in destination -> updated. */
|
|
55
|
+
export function updated(destOrg, from, to) {
|
|
56
|
+
info(`${CHECK} Repository found in ${bold(destOrg)}.`);
|
|
57
|
+
info(`${CHECK} Updated origin successfully.`);
|
|
58
|
+
info("");
|
|
59
|
+
info(dim(` ${from}`));
|
|
60
|
+
info(dim(" ↓"));
|
|
61
|
+
info(` ${green(to)}`);
|
|
62
|
+
info("");
|
|
63
|
+
info("Please run:");
|
|
64
|
+
info("");
|
|
65
|
+
info(bold(" git push"));
|
|
66
|
+
}
|
|
67
|
+
/** Step 4: not found anywhere. */
|
|
68
|
+
export function notFoundAnywhere(repo, checked) {
|
|
69
|
+
error(`Repository '${repo}' not found in either organization.`);
|
|
70
|
+
info("");
|
|
71
|
+
info("Checked:");
|
|
72
|
+
for (const org of checked)
|
|
73
|
+
info(` • ${org}`);
|
|
74
|
+
info("");
|
|
75
|
+
info("Please verify that the repository has been migrated.");
|
|
76
|
+
}
|
|
77
|
+
/** A git probe returned an unclassifiable error. */
|
|
78
|
+
export function probeFailed(url, detailText) {
|
|
79
|
+
error("Could not reach the remote to verify it:");
|
|
80
|
+
console.error(` ${url}`);
|
|
81
|
+
if (detailText)
|
|
82
|
+
console.error(dim(detailText));
|
|
83
|
+
}
|
package/migrations.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "appmaker-git-remote-origin-fixer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Recovery CLI that repairs a local git origin remote after a GitHub organization migration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"appmaker-git-remote-origin-fixer": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"migrations.json"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsc --watch",
|
|
19
|
+
"start": "node dist/cli.js",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"git",
|
|
24
|
+
"remote",
|
|
25
|
+
"origin",
|
|
26
|
+
"github",
|
|
27
|
+
"organization",
|
|
28
|
+
"migration",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^24.10.1",
|
|
34
|
+
"typescript": "^5.9.3"
|
|
35
|
+
}
|
|
36
|
+
}
|