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 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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point: parse arguments, run the fixer, set the exit code.
4
+ */
5
+ export {};
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());
@@ -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
+ }
@@ -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}` : 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
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "migrations": {
3
+ "AppMakerHQ": "Appmaker-xyz",
4
+ "AppMakerPartnersHQ": "Appmaker-Partners",
5
+ "TestorOrg": "TestorOrg2"
6
+ }
7
+ }
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
+ }