appmaker-git-remote-origin-fixer 0.1.0 → 0.1.4

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 CHANGED
@@ -3,17 +3,22 @@
3
3
  A small recovery CLI that repairs a local git `origin` remote after a GitHub
4
4
  **organization migration**.
5
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.
6
+ When repositories are moved from one GitHub organization to another and the old
7
+ copy is then **deleted** or **archived** every developer's local clone still
8
+ points at the old remote, so `git push` fails. Run this tool once, after that
9
+ first failed push, and it repoints `origin` at the new organization for you.
10
10
 
11
11
  ```
12
- git push # ❌ fails — repo no longer exists in the old org
12
+ git push # ❌ fails — repo was moved out of the old org
13
13
  appmaker-git-remote-origin-fixer # 🔧 diagnoses and fixes origin
14
14
  git push # ✅ works
15
15
  ```
16
16
 
17
+ > **Who this is for:** the package ships with **Appmaker.xyz**'s migration
18
+ > configuration by default, so Appmaker developers can just install and run it.
19
+ > It is not Appmaker-specific, though — the migration rules are pure data, so any
20
+ > team can reuse it by supplying their own config (see [Configuration](#configuration)).
21
+
17
22
  ## Install
18
23
 
19
24
  ```bash
@@ -31,6 +36,8 @@ appmaker-git-remote-origin-fixer [options]
31
36
 
32
37
  -c, --config <path> Use a specific migrations config file
33
38
  -n, --dry-run Diagnose only; do not modify the remote
39
+ -f, --force Rewrite origin to the destination unconditionally,
40
+ without checking the source or destination
34
41
  -h, --help Show help
35
42
  -v, --version Show version
36
43
  ```
@@ -44,25 +51,46 @@ Read origin URL → parse org + repo
44
51
  Is the org a "source" in the migration config?
45
52
  │ no → "No migration configured" → exit
46
53
  ▼ 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)
54
+ What state is the repo in the SOURCE org?
55
+ ├─ healthy (push works) → "still exists, nothing to do"
56
+ ├─ archived (read-only) migrate
57
+ └─ deleted (not found) → migrate
58
+
59
+
60
+ Is the repo reachable in the DESTINATION org? (git ls-remote)
52
61
  ├─ reachable → rewrite origin → "run git push"
53
62
  ├─ no permission → "exists, contact admin" (remote left unchanged)
54
63
  └─ not found → "not found in either org"
55
64
  ```
56
65
 
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.
66
+ Detection uses your **existing git credentials** no GitHub token or API access:
67
+
68
+ - **Existence** is checked with `git ls-remote` (a read).
69
+ - **Archived/read-only** is detected with a `git push --dry-run` probe: it sends
70
+ nothing and creates nothing, but the server still reports if the repo is
71
+ archived. (An archived repo reads fine, so a read alone can't detect it.)
72
+
73
+ The remote is only rewritten once the destination is confirmed reachable —
74
+ **except** under `--force` (see below).
75
+
76
+ > **Note:** detection is most reliable with **SSH remotes**
77
+ > (`git@github.com:Org/repo.git`), where GitHub returns clear messages. Over
78
+ > HTTPS without cached credentials git may be unable to determine state; in that
79
+ > case the tool leaves your remote untouched.
80
+
81
+ ### `--force`
82
+
83
+ GitHub reports a **private repo you don't have access to** exactly like a
84
+ missing one ("Repository not found") — so the destination check can't see it,
85
+ and the tool would refuse to switch. If you *know* the repo was migrated and you
86
+ will get access, run with `--force`:
87
+
88
+ ```bash
89
+ appmaker-git-remote-origin-fixer --force
90
+ ```
60
91
 
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.
92
+ `--force` rewrites `origin` to the destination **unconditionally**, skipping both
93
+ the source and destination checks. The next `git push` is then the real test.
66
94
 
67
95
  ## Configuration
68
96
 
@@ -82,9 +110,10 @@ The config is resolved in this order (first found wins):
82
110
  1. `--config <path>`
83
111
  2. `./migrations.json` (current directory)
84
112
  3. `~/.appmaker-git-remote-origin-fixer.json` (per-user override)
85
- 4. the `migrations.json` bundled with the package
113
+ 4. the `migrations.json` bundled with the package (Appmaker.xyz's mappings)
86
114
 
87
- To support a new migration, update the config — no code change needed.
115
+ To support a new migration, update the config — no code change needed. Other
116
+ teams can use the tool by overriding the config via any of options 1–3 above.
88
117
 
89
118
  ## Development
90
119
 
package/dist/cli.js CHANGED
@@ -30,6 +30,10 @@ USAGE
30
30
  OPTIONS
31
31
  -c, --config <path> Use a specific migrations config file.
32
32
  -n, --dry-run Diagnose only; do not modify the remote.
33
+ -f, --force Rewrite origin to the destination unconditionally,
34
+ without checking the source or destination. Use when
35
+ the source is archived, or the destination is private
36
+ and you do not have access yet.
33
37
  -h, --help Show this help.
34
38
  -v, --version Show the version.
35
39
  `;
@@ -40,6 +44,7 @@ function main() {
40
44
  options: {
41
45
  config: { type: "string", short: "c" },
42
46
  "dry-run": { type: "boolean", short: "n" },
47
+ force: { type: "boolean", short: "f" },
43
48
  help: { type: "boolean", short: "h" },
44
49
  version: { type: "boolean", short: "v" },
45
50
  },
@@ -64,6 +69,7 @@ function main() {
64
69
  return run({
65
70
  configPath: values.config,
66
71
  dryRun: values["dry-run"],
72
+ force: values.force,
67
73
  });
68
74
  }
69
75
  catch (err) {
package/dist/git.d.ts CHANGED
@@ -25,3 +25,18 @@ export declare function setOriginUrl(url: string): void;
25
25
  * can distinguish "missing" from "no permission".
26
26
  */
27
27
  export declare function probeRemote(url: string): ProbeResult;
28
+ export type PushStatus = "writable" | "archived" | "forbidden" | "not_found" | "unknown";
29
+ export interface PushProbeResult {
30
+ status: PushStatus;
31
+ detail: string;
32
+ }
33
+ /**
34
+ * Probe whether the current repo could PUSH to `url`, without actually pushing.
35
+ *
36
+ * A `git push --dry-run` of the current commit to a throwaway ref sends nothing
37
+ * and creates nothing, but GitHub still enforces archived/permission checks
38
+ * during negotiation — so this distinguishes a writable repo from one that is
39
+ * archived (read-only). Requires a local commit to push; if HEAD is unborn the
40
+ * probe is inconclusive ("unknown").
41
+ */
42
+ export declare function probePushable(url: string): PushProbeResult;
package/dist/git.js CHANGED
@@ -87,3 +87,54 @@ export function probeRemote(url) {
87
87
  process.env.GIT_TERMINAL_PROMPT = prevPrompt;
88
88
  }
89
89
  }
90
+ /**
91
+ * Probe whether the current repo could PUSH to `url`, without actually pushing.
92
+ *
93
+ * A `git push --dry-run` of the current commit to a throwaway ref sends nothing
94
+ * and creates nothing, but GitHub still enforces archived/permission checks
95
+ * during negotiation — so this distinguishes a writable repo from one that is
96
+ * archived (read-only). Requires a local commit to push; if HEAD is unborn the
97
+ * probe is inconclusive ("unknown").
98
+ */
99
+ export function probePushable(url) {
100
+ if (!runGit(["rev-parse", "--verify", "HEAD"]).ok) {
101
+ return { status: "unknown", detail: "no local commit to probe with" };
102
+ }
103
+ const prevPrompt = process.env.GIT_TERMINAL_PROMPT;
104
+ process.env.GIT_TERMINAL_PROMPT = "0";
105
+ try {
106
+ const result = runGit([
107
+ "push",
108
+ "--dry-run",
109
+ url,
110
+ "HEAD:refs/heads/__appmaker_origin_fixer_probe__",
111
+ ]);
112
+ if (result.ok)
113
+ return { status: "writable", detail: "" };
114
+ const stderr = result.stderr.toLowerCase();
115
+ // Check archived first: GitHub's archived error also contains phrases that
116
+ // match the not_found patterns below.
117
+ if (stderr.includes("archived") || stderr.includes("read-only") || stderr.includes("read only")) {
118
+ return { status: "archived", detail: result.stderr };
119
+ }
120
+ if (stderr.includes("permission") ||
121
+ stderr.includes("denied") ||
122
+ stderr.includes("not authorized") ||
123
+ stderr.includes("authentication failed") ||
124
+ stderr.includes("403")) {
125
+ return { status: "forbidden", detail: result.stderr };
126
+ }
127
+ if (stderr.includes("repository not found") ||
128
+ stderr.includes("not found") ||
129
+ stderr.includes("does not exist")) {
130
+ return { status: "not_found", detail: result.stderr };
131
+ }
132
+ return { status: "unknown", detail: result.stderr };
133
+ }
134
+ finally {
135
+ if (prevPrompt === undefined)
136
+ delete process.env.GIT_TERMINAL_PROMPT;
137
+ else
138
+ process.env.GIT_TERMINAL_PROMPT = prevPrompt;
139
+ }
140
+ }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,12 @@ export interface RunOptions {
12
12
  configPath?: string;
13
13
  /** Diagnose only; never modify the remote. */
14
14
  dryRun?: boolean;
15
+ /**
16
+ * Skip the "does the source still exist?" check and switch to the
17
+ * destination regardless of source state (e.g. when the source repo is
18
+ * archived and read-only). The destination is still verified first.
19
+ */
20
+ force?: boolean;
15
21
  }
16
22
  /** Returns a process exit code. */
17
23
  export declare function run(options?: RunOptions): number;
package/dist/index.js CHANGED
@@ -8,9 +8,37 @@
8
8
  * 5. Probe destination -> found -> rewrite origin; else explain
9
9
  */
10
10
  import { loadConfig } from "./config.js";
11
- import { getOriginUrl, isGitRepo, probeRemote, setOriginUrl, } from "./git.js";
11
+ import { getOriginUrl, isGitRepo, probePushable, probeRemote, setOriginUrl, } from "./git.js";
12
12
  import { parseRemote, rewriteOrg } from "./remote-url.js";
13
13
  import * as ui from "./ui.js";
14
+ /**
15
+ * Verify the destination repo is reachable and, if so, rewrite origin to it.
16
+ * Shared by the normal flow and --force. Returns a process exit code.
17
+ */
18
+ function switchToDestination(parsed, destOrg, originUrl, dryRun) {
19
+ const destUrl = rewriteOrg(parsed, destOrg);
20
+ const dest = probeRemote(destUrl);
21
+ if (dest.status === "reachable") {
22
+ if (dryRun) {
23
+ ui.info("(dry run) Would update origin:");
24
+ ui.info(` ${originUrl} -> ${destUrl}`);
25
+ return 0;
26
+ }
27
+ setOriginUrl(destUrl);
28
+ ui.updated(destOrg, originUrl, destUrl);
29
+ return 0;
30
+ }
31
+ if (dest.status === "forbidden") {
32
+ ui.permissionDenied(destOrg, parsed.repo);
33
+ return 1;
34
+ }
35
+ if (dest.status === "unknown") {
36
+ ui.probeFailed(destUrl, dest.detail);
37
+ return 1;
38
+ }
39
+ // not_found
40
+ return -1; // caller decides the message (differs for force vs normal flow)
41
+ }
14
42
  /** Returns a process exit code. */
15
43
  export function run(options = {}) {
16
44
  if (!isGitRepo()) {
@@ -33,10 +61,39 @@ export function run(options = {}) {
33
61
  ui.noMigration(parsed.org);
34
62
  return 0; // Not an error — just nothing to do.
35
63
  }
64
+ // --force: rewrite origin to the destination unconditionally, without
65
+ // probing either the source or the destination. Useful when the source is
66
+ // archived, or when the destination is private and the user has no access
67
+ // yet (GitHub reports those as "not found"). The destination is NOT verified.
68
+ if (options.force) {
69
+ const destUrl = rewriteOrg(parsed, destOrg);
70
+ if (options.dryRun) {
71
+ ui.info("(dry run) Would update origin:");
72
+ ui.info(` ${originUrl} -> ${destUrl}`);
73
+ return 0;
74
+ }
75
+ setOriginUrl(destUrl);
76
+ ui.forcedUpdate(destOrg, originUrl, destUrl);
77
+ return 0;
78
+ }
36
79
  // Step 4 — is the repo still in the source org?
37
80
  const source = probeRemote(originUrl);
38
81
  if (source.status === "reachable") {
39
- ui.stillExists(parsed.org, parsed.repo);
82
+ // Readable — but an archived repo also reads fine. Probe push capability to
83
+ // tell a healthy repo from an archived (read-only) one.
84
+ const push = probePushable(originUrl);
85
+ if (push.status === "archived") {
86
+ ui.archivedSource(parsed.org, parsed.repo, destOrg);
87
+ const code = switchToDestination(parsed, destOrg, originUrl, !!options.dryRun);
88
+ if (code === -1) {
89
+ // Archived source, but destination not reachable (e.g. private/no access).
90
+ ui.archivedDestNotFound(destOrg, parsed.repo);
91
+ return 1;
92
+ }
93
+ return code;
94
+ }
95
+ // Writable or inconclusive — treat as genuinely present, nothing to do.
96
+ ui.stillExists(parsed.org, parsed.repo, destOrg);
40
97
  return 0;
41
98
  }
42
99
  if (source.status === "forbidden") {
@@ -49,27 +106,11 @@ export function run(options = {}) {
49
106
  }
50
107
  // status === "not_found" -> continue to the destination.
51
108
  // 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);
109
+ const code = switchToDestination(parsed, destOrg, originUrl, !!options.dryRun);
110
+ if (code === -1) {
111
+ // Not found in either org.
112
+ ui.notFoundAnywhere(parsed.repo, [parsed.org, destOrg]);
70
113
  return 1;
71
114
  }
72
- // Not found in either org.
73
- ui.notFoundAnywhere(parsed.repo, [parsed.org, destOrg]);
74
- return 1;
115
+ return code;
75
116
  }
package/dist/ui.d.ts CHANGED
@@ -14,7 +14,13 @@ export declare function unparseableRemote(url: string): void;
14
14
  /** Step 2: no migration configured for this org. */
15
15
  export declare function noMigration(org: string): void;
16
16
  /** Step 3: repository still exists in the source org. */
17
- export declare function stillExists(org: string, repo: string): void;
17
+ export declare function stillExists(org: string, repo: string, destOrg: string): void;
18
+ /** Auto-detected: source repo is archived (read-only); migrating. */
19
+ export declare function archivedSource(org: string, repo: string, destOrg: string): void;
20
+ /** Auto-detected archived source, but the destination could not be reached. */
21
+ export declare function archivedDestNotFound(destOrg: string, repo: string): void;
22
+ /** --force: origin rewritten without verifying the destination. */
23
+ export declare function forcedUpdate(destOrg: string, from: string, to: string): void;
18
24
  /** Step 3/4: permission denied on a repository. */
19
25
  export declare function permissionDenied(org: string, repo: string): void;
20
26
  /** Step 4: found in destination -> updated. */
package/dist/ui.js CHANGED
@@ -41,9 +41,39 @@ export function noMigration(org) {
41
41
  info(`No migration configured for organization:\n\n ${bold(org)}`);
42
42
  }
43
43
  /** Step 3: repository still exists in the source org. */
44
- export function stillExists(org, repo) {
44
+ export function stillExists(org, repo, destOrg) {
45
45
  info(`${CHECK} Repository ${bold(`${org}/${repo}`)} still exists.`);
46
46
  info("No changes required.");
47
+ info("");
48
+ info(dim(`If your push still fails (e.g. the repo was archived), re-run with ` +
49
+ `--force to switch to ${destOrg} anyway.`));
50
+ }
51
+ /** Auto-detected: source repo is archived (read-only); migrating. */
52
+ export function archivedSource(org, repo, destOrg) {
53
+ info(`${yellow("!")} Repository ${bold(`${org}/${repo}`)} is archived (read-only).`);
54
+ info(`Migrating origin to ${bold(destOrg)}...`);
55
+ info("");
56
+ }
57
+ /** Auto-detected archived source, but the destination could not be reached. */
58
+ export function archivedDestNotFound(destOrg, repo) {
59
+ error(`Source is archived, but ${bold(`${destOrg}/${repo}`)} was not found.`);
60
+ info("It may be private and you may not have access yet.");
61
+ info(dim("If you are sure it exists there, re-run with --force to switch anyway."));
62
+ }
63
+ /** --force: origin rewritten without verifying the destination. */
64
+ export function forcedUpdate(destOrg, from, to) {
65
+ info(`${CHECK} Updated origin to ${bold(destOrg)} ${dim("(forced — destination not verified)")}.`);
66
+ info("");
67
+ info(dim(` ${from}`));
68
+ info(dim(" ↓"));
69
+ info(` ${green(to)}`);
70
+ info("");
71
+ info(yellow("Note: the destination was not checked. If the repository does not"));
72
+ info(yellow("exist there, or you do not have access yet, your next push will fail."));
73
+ info("");
74
+ info("Please run:");
75
+ info("");
76
+ info(bold(" git push"));
47
77
  }
48
78
  /** Step 3/4: permission denied on a repository. */
49
79
  export function permissionDenied(org, repo) {
package/migrations.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "migrations": {
3
3
  "AppMakerHQ": "Appmaker-xyz",
4
4
  "AppMakerPartnersHQ": "Appmaker-Partners",
5
- "TestorOrg": "TestorOrg2"
5
+ "Appmaker-xyz": "AppMakerHQ",
6
+ "Appmaker-Partners": "AppMakerPartnersHQ"
6
7
  }
7
8
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "appmaker-git-remote-origin-fixer",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Recovery CLI that repairs a local git origin remote after a GitHub organization migration.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "appmaker-git-remote-origin-fixer": "./dist/cli.js"
7
+ "appmaker-git-remote-origin-fixer": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",
@@ -14,7 +14,7 @@
14
14
  "node": ">=18"
15
15
  },
16
16
  "scripts": {
17
- "build": "tsc",
17
+ "build": "tsc && chmod +x dist/cli.js",
18
18
  "dev": "tsc --watch",
19
19
  "start": "node dist/cli.js",
20
20
  "prepublishOnly": "npm run build"