@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -24,29 +24,42 @@
24
24
  * hand + restarting the vault.
25
25
  */
26
26
 
27
+ import { existsSync } from "node:fs";
28
+
27
29
  import {
28
30
  defaultMirrorConfig,
31
+ mirrorConfigPath,
32
+ readMirrorConfigForVault,
29
33
  validateExternalPath,
30
34
  validateMirrorConfigShape,
31
35
  type MirrorConfig,
32
36
  } from "./mirror-config.ts";
33
37
  import type { MirrorManager } from "./mirror-manager.ts";
38
+ import { getMirrorManager } from "./mirror-registry.ts";
34
39
  import {
35
40
  applyToGitRemote,
36
41
  deleteCredentials,
37
42
  emptyCredentials,
43
+ githubAuthedRemoteUrl,
38
44
  readCredentials,
45
+ redactRemoteUrl,
39
46
  sanitizeCredentials,
40
47
  unsetGitRemote,
41
48
  writeCredentials,
42
49
  type MirrorCredentials,
43
50
  } from "./mirror-credentials.ts";
44
51
  import {
52
+ GITHUB_APP_SLUG_DEFAULT,
53
+ GITHUB_CLIENT_ID_DEFAULT,
54
+ GitHubApiError,
45
55
  createRepo,
46
56
  fetchUser,
57
+ getGithubAppSlug,
47
58
  getGithubClientId,
59
+ installUrlForSlug,
48
60
  isPlaceholderClientId,
49
- listRepos,
61
+ listInstallationRepos,
62
+ listInstallations,
50
63
  pollForToken,
51
64
  requestDeviceCode,
52
65
  type FetchLike,
@@ -62,6 +75,7 @@ import {
62
75
  type ImportResult,
63
76
  } from "./mirror-import.ts";
64
77
  import { redactToken } from "./export-watch.ts";
78
+ import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
65
79
  import { getVaultStore } from "./vault-store.ts";
66
80
  import { assetsDir } from "./routes.ts";
67
81
 
@@ -120,6 +134,10 @@ export function handleMirrorGet(manager: MirrorManager): Response {
120
134
  export async function handleMirrorPut(
121
135
  req: Request,
122
136
  manager: MirrorManager,
137
+ // Test seam for the external-path git-presence preflight (default
138
+ // `Bun.which` inside `validateExternalPath`). Inject a fn returning `null`
139
+ // to exercise the git_not_installed 503 path without uninstalling git.
140
+ whichOverride?: (cmd: string) => string | null,
123
141
  ): Promise<Response> {
124
142
  let body: unknown;
125
143
  try {
@@ -154,7 +172,25 @@ export async function handleMirrorPut(
154
172
  // to *do* something with an external path. Disabling the mirror by-
155
173
  // flipping enabled to false shouldn't fail because the path went away.
156
174
  if (config.enabled && config.location === "external" && config.external_path) {
157
- const pathCheck = await validateExternalPath(config.external_path);
175
+ let pathCheck;
176
+ try {
177
+ pathCheck = await validateExternalPath(config.external_path, whichOverride);
178
+ } catch (err) {
179
+ if (err instanceof GitNotInstalledError) {
180
+ // 503 git_not_installed — consistent with the import route. The
181
+ // server can't validate (or later sync) an external git mirror
182
+ // without git installed; the message tells the operator how to fix.
183
+ return Response.json(
184
+ {
185
+ error: "git not installed",
186
+ error_type: "git_not_installed",
187
+ message: err.message,
188
+ },
189
+ { status: 503 },
190
+ );
191
+ }
192
+ throw err;
193
+ }
158
194
  if (!pathCheck.ok) {
159
195
  return Response.json(
160
196
  {
@@ -271,9 +307,10 @@ export function buildMirrorGetResponse(
271
307
  }
272
308
 
273
309
  // ---------------------------------------------------------------------------
274
- // Credential routes — Cut 3 of the UI-configurable push credentials work.
310
+ // Credential routes — Cut 3 of the UI-configurable push credentials work,
311
+ // reshaped by the GitHub-App installation semantics (vault#480).
275
312
  //
276
- // Six surfaces, all `vault:<name>:admin`-gated upstream:
313
+ // Surfaces, all `vault:<name>:admin`-gated upstream:
277
314
  //
278
315
  // POST /.parachute/mirror/auth/github/device-code — start GitHub Device
279
316
  // Flow; returns { polling_id, user_code, verification_uri, expires_in,
@@ -281,19 +318,29 @@ export function buildMirrorGetResponse(
281
318
  // by polling_id (a short opaque token) so the device_code doesn't
282
319
  // land on the wire twice.
283
320
  // POST /.parachute/mirror/auth/github/poll — poll for token, body
284
- // { polling_id }. On `granted`: fetch user, save credentials, set
285
- // remote URL, return { state: "granted", user }. Other states
286
- // surface verbatim.
321
+ // { polling_id }. On `granted`: fetch user, save credentials, enable
322
+ // history for a never-configured vault (vault#483), return
323
+ // { state: "granted", user, history_enabled }. Other states surface
324
+ // verbatim.
287
325
  // POST /.parachute/mirror/auth/pat — validate + store a
288
326
  // PAT (token + remote_url + label). Validates via `git ls-remote`.
327
+ // Same history-on-link behavior as the poll grant (vault#483).
289
328
  // GET /.parachute/mirror/auth — current connection
290
- // status (NO secrets). Returns the sanitized public shape.
329
+ // status (NO secrets, NO network). Returns the sanitized public shape.
291
330
  // DELETE /.parachute/mirror/auth — wipe credentials,
292
331
  // unset embedded-credential remote URL.
293
- // GET /.parachute/mirror/auth/github/repos list operator's
294
- // GitHub repos via stored OAuth token.
332
+ // GET /.parachute/mirror/auth/github/installations install state
333
+ // (vault#480): which app, whether it's installed anywhere, the
334
+ // install link, and the per-account installations. The one
335
+ // explicitly-network status endpoint — `GET /auth` stays offline.
336
+ // GET /.parachute/mirror/auth/github/repos — list the repos the
337
+ // operator's app INSTALLATIONS grant (user + org accounts), via the
338
+ // stored OAuth token. Returns { installed: false, install_url,
339
+ // repos: [] } when the app isn't installed anywhere yet.
295
340
  // POST /.parachute/mirror/auth/github/create-repo — create a new private
296
- // repo on behalf of the operator.
341
+ // repo on behalf of the operator. 403s with the shared Contents-only
342
+ // app (mapped to error_type "app_lacks_admin_permission" + the
343
+ // guided-manual path); works for BYO apps with Administration:write.
297
344
  //
298
345
  // ---------------------------------------------------------------------------
299
346
 
@@ -336,16 +383,18 @@ export function _resetDeviceFlowSessionsForTest(): void {
336
383
  }
337
384
 
338
385
  /**
339
- * Errors out cleanly when the operator hasn't replaced the placeholder
340
- * client_id. The user-facing message explains the next step.
386
+ * Errors out cleanly when PARACHUTE_GITHUB_CLIENT_ID is set to a
387
+ * placeholder-shaped value. A real default ships in the build (the shared
388
+ * Parachute GitHub App — see github-device-flow.ts), so this is only
389
+ * reachable via a misconfigured override.
341
390
  */
342
391
  function placeholderClientIdResponse(): Response {
343
392
  return Response.json(
344
393
  {
345
- error: "GitHub OAuth not configured",
394
+ error: "GitHub auth misconfigured",
346
395
  error_type: "placeholder_client_id",
347
396
  message:
348
- "This Parachute Vault build doesn't have a registered GitHub OAuth App client_id. Set the PARACHUTE_GITHUB_CLIENT_ID environment variable (see src/github-device-flow.ts for setup steps) or use the Personal Access Token path instead.",
397
+ "PARACHUTE_GITHUB_CLIENT_ID is set to a placeholder value. Unset it to use the built-in shared Parachute GitHub App, set it to your own app's client_id, or use the Personal Access Token path instead.",
349
398
  },
350
399
  { status: 503 },
351
400
  );
@@ -488,12 +537,12 @@ export async function handleAuthGithubPoll(
488
537
  }
489
538
  // Clean up the polling session.
490
539
  deviceFlowSessions.delete(body.polling_id);
491
- // Apply to git remote if mirror is currently running on an external
492
- // path that's a git repo. The credentials become active on next push;
493
- // the operator doesn't have to restart vault. We don't have an owner/
494
- // repo yet (the operator hasn't picked a repo) that wiring happens
495
- // in the create-repo or repo-picked path. So at this point we just
496
- // store credentials; the URL gets set when the operator picks a repo.
540
+ // vault#483: linking implies backup intent enable history for a
541
+ // never-configured vault (consent-respecting; see the helper). No
542
+ // remote URL is set here we don't have an owner/repo yet (the
543
+ // operator hasn't picked a repo); that wiring happens in select-repo,
544
+ // which is also where auto_push flips on (Cut 3).
545
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
497
546
  return Response.json(
498
547
  {
499
548
  state: "granted",
@@ -503,6 +552,7 @@ export async function handleAuthGithubPoll(
503
552
  name: user.name,
504
553
  avatar_url: user.avatar_url,
505
554
  },
555
+ history_enabled,
506
556
  },
507
557
  { headers: { "Access-Control-Allow-Origin": "*" } },
508
558
  );
@@ -536,6 +586,14 @@ export async function handleAuthGithubPoll(
536
586
  export async function handleAuthPat(
537
587
  req: Request,
538
588
  manager: MirrorManager,
589
+ // Test seam for the `git ls-remote` validation probe (default
590
+ // `probeGitLsRemote`, which spawns real git against the supplied remote).
591
+ // Inject a fn returning `{ok: true}` to exercise the post-validation save
592
+ // path (credential persist + history-on-link, vault#483) hermetically.
593
+ probeOverride?: (
594
+ url: string,
595
+ timeoutMs: number,
596
+ ) => Promise<{ ok: boolean; error?: string }>,
539
597
  ): Promise<Response> {
540
598
  let body: { token?: unknown; remote_url?: unknown; label?: unknown };
541
599
  try {
@@ -597,6 +655,26 @@ export async function handleAuthPat(
597
655
  );
598
656
  }
599
657
 
658
+ // Preflight: the validation probe (`git ls-remote`) and every later push
659
+ // shell `git`. On a git-less server, surface the friendly, actionable
660
+ // 503 instead of letting the probe's `Bun.spawn` throw a raw
661
+ // "Executable not found in $PATH: \"git\"".
662
+ try {
663
+ ensureGitAvailable();
664
+ } catch (err) {
665
+ if (err instanceof GitNotInstalledError) {
666
+ return Response.json(
667
+ {
668
+ error: "git not installed",
669
+ error_type: "git_not_installed",
670
+ message: err.message,
671
+ },
672
+ { status: 503 },
673
+ );
674
+ }
675
+ throw err;
676
+ }
677
+
600
678
  // Validate via `git ls-remote <embedded-auth-url>` — uses the same
601
679
  // x-access-token shape we'd embed at push time so the probe exercises
602
680
  // the actual auth path. If the operator pasted a URL that already has
@@ -610,7 +688,7 @@ export async function handleAuthPat(
610
688
  u.password = token;
611
689
  return u.toString();
612
690
  })();
613
- const probeResult = await probeGitLsRemote(authedUrl, 10_000);
691
+ const probeResult = await (probeOverride ?? probeGitLsRemote)(authedUrl, 10_000);
614
692
  if (!probeResult.ok) {
615
693
  return Response.json(
616
694
  {
@@ -647,6 +725,15 @@ export async function handleAuthPat(
647
725
  );
648
726
  }
649
727
 
728
+ // vault#483: linking implies backup intent — enable history for a
729
+ // never-configured vault BEFORE the Cut-3/Cut-6 steps below, so a fresh
730
+ // vault gets the whole intended flow in one save: history on (the reload's
731
+ // start() applies the just-written PAT remote to `origin`), then Cut 3
732
+ // sees an enabled mirror and flips auto_push on, then Cut 6 fires the
733
+ // initial push. An explicitly-disabled mirror short-circuits all of it
734
+ // (history stays off → maybeEnableAutoPush no-ops on disabled).
735
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
736
+
650
737
  // Push the new URL onto the mirror's git remote if it's currently
651
738
  // resolved + on disk. Non-fatal if the mirror isn't running.
652
739
  await applyCredentialsToMirror(manager);
@@ -666,6 +753,7 @@ export async function handleAuthPat(
666
753
  return Response.json(
667
754
  {
668
755
  ...sanitizeCredentials(next),
756
+ history_enabled,
669
757
  auto_push_was_already_enabled: autoPushChange.was_already_enabled,
670
758
  auto_push_enabled: autoPushChange.auto_push_now_enabled,
671
759
  initial_push: initialPush,
@@ -764,8 +852,102 @@ export async function handleAuthDelete(manager: MirrorManager): Promise<Response
764
852
  }
765
853
 
766
854
  /**
767
- * `GET /.parachute/mirror/auth/github/repos` — list operator's repos via
768
- * the stored OAuth token. Requires `active_method === "github_oauth"`.
855
+ * `GET /.parachute/mirror/auth/github/installations` — install state for
856
+ * the connect flow (vault#480). Answers three UI questions in one call:
857
+ * which app is in play (shared default vs BYO), is it installed ANYWHERE
858
+ * for this operator, and which accounts (user/orgs) carry installations.
859
+ *
860
+ * Deliberately a separate, explicitly-network endpoint: `GET /auth` stays
861
+ * a pure local read of the stored credential. The SPA calls this when it
862
+ * renders the connect flow / repo picker, not on every status poll.
863
+ *
864
+ * Response:
865
+ * 200 {
866
+ * app: { client_id, slug, is_shared_default },
867
+ * installed: boolean, // installations.length > 0
868
+ * install_url: string, // github.com/apps/<slug>/installations/new
869
+ * installations: [{ id, account_login, account_type, repository_selection }]
870
+ * }
871
+ * 400 { error, error_type: "github_not_connected", message } — no stored
872
+ * github_oauth credential; the device flow hasn't been run (or a PAT
873
+ * is active instead — install state is a GitHub-App concept). 400,
874
+ * not 401, matching the sibling repos handler: a 401 here would trip
875
+ * the SPA's authedFetch token-refresh machinery and clear a
876
+ * perfectly valid cached admin token over a non-auth condition.
877
+ * 502 { error, message } — GitHub unreachable / API error.
878
+ */
879
+ export async function handleAuthGithubInstallations(
880
+ manager: MirrorManager,
881
+ fetchImpl?: FetchLike,
882
+ ): Promise<Response> {
883
+ const creds = readCredentials(manager.getVaultName());
884
+ if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
885
+ return Response.json(
886
+ {
887
+ error: "Not connected to GitHub",
888
+ error_type: "github_not_connected",
889
+ message:
890
+ "No GitHub sign-in is stored for this vault. Run the device flow first (POST /.parachute/mirror/auth/github/device-code).",
891
+ },
892
+ { status: 400 },
893
+ );
894
+ }
895
+ const clientId = getGithubClientId();
896
+ const slug = getGithubAppSlug();
897
+ let installations;
898
+ try {
899
+ installations = await listInstallations(creds.github_oauth.access_token, fetchImpl);
900
+ } catch (err) {
901
+ return Response.json(
902
+ {
903
+ error: "Installation check failed",
904
+ message: (err as Error).message ?? String(err),
905
+ },
906
+ { status: 502 },
907
+ );
908
+ }
909
+ return Response.json(
910
+ {
911
+ app: {
912
+ client_id: clientId,
913
+ slug,
914
+ is_shared_default:
915
+ clientId === GITHUB_CLIENT_ID_DEFAULT && slug === GITHUB_APP_SLUG_DEFAULT,
916
+ },
917
+ installed: installations.length > 0,
918
+ install_url: installUrlForSlug(slug),
919
+ installations: installations.map((i) => ({
920
+ id: i.id,
921
+ account_login: i.account.login,
922
+ account_type: i.account.type,
923
+ repository_selection: i.repository_selection,
924
+ })),
925
+ },
926
+ { headers: { "Access-Control-Allow-Origin": "*" } },
927
+ );
928
+ }
929
+
930
+ /**
931
+ * `GET /.parachute/mirror/auth/github/repos` — list the repos the
932
+ * operator's app installations grant, via the stored OAuth token. Requires
933
+ * `active_method === "github_oauth"`.
934
+ *
935
+ * Source (vault#480): `GET /user/installations` → per-installation
936
+ * `GET /user/installations/{id}/repositories`, unioned. This replaces the
937
+ * old `GET /user/repos?type=owner` source, which (a) excluded org-owned
938
+ * repos by construction and (b) showed all-public-repos when the app wasn't
939
+ * installed at all (every GitHub App reads public repos) — Aaron walked
940
+ * into exactly that "looks connected, shows the wrong repos" state live.
941
+ *
942
+ * Response:
943
+ * 200 installed: { installed: true, repos: [{ ...GitHubRepoInfo,
944
+ * account_login, installation_id }], truncated }
945
+ * 200 not installed: { installed: false, install_url, repos: [],
946
+ * truncated: false } — machine-readable
947
+ * authorized-but-not-installed state; the UI shows
948
+ * the guided-install step, no string-matching needed.
949
+ * 400 { error, message } — no github_oauth credential stored.
950
+ * 502 { error, message } — GitHub unreachable / API error.
769
951
  */
770
952
  export async function handleAuthGithubRepos(
771
953
  manager: MirrorManager,
@@ -781,9 +963,58 @@ export async function handleAuthGithubRepos(
781
963
  { status: 400 },
782
964
  );
783
965
  }
784
- let result;
966
+ const token = creds.github_oauth.access_token;
967
+ let installations;
968
+ try {
969
+ installations = await listInstallations(token, fetchImpl);
970
+ } catch (err) {
971
+ return Response.json(
972
+ {
973
+ error: "Repo list failed",
974
+ message: (err as Error).message ?? String(err),
975
+ },
976
+ { status: 502 },
977
+ );
978
+ }
979
+
980
+ if (installations.length === 0) {
981
+ // Authorized but not installed — the device-flow grant alone reaches no
982
+ // repos. Distinct, machine-readable state (NOT an empty repo list that
983
+ // looks like "you have no repos").
984
+ return Response.json(
985
+ {
986
+ installed: false,
987
+ install_url: installUrlForSlug(getGithubAppSlug()),
988
+ repos: [],
989
+ truncated: false,
990
+ },
991
+ { headers: { "Access-Control-Allow-Origin": "*" } },
992
+ );
993
+ }
994
+
995
+ const repos: Array<GitHubRepoInfo & { account_login: string; installation_id: number }> = [];
996
+ let truncated = false;
785
997
  try {
786
- result = await listRepos(creds.github_oauth.access_token, {}, fetchImpl);
998
+ for (const installation of installations) {
999
+ const result = await listInstallationRepos(token, installation.id, {}, fetchImpl);
1000
+ // `GET /user/installations/{id}/repositories` takes no `sort` param
1001
+ // (unlike the old `GET /user/repos?sort=updated` picker source), so
1002
+ // order within each account group ourselves: most-recently-updated
1003
+ // first, so the repo the operator probably wants sits near the top.
1004
+ // ISO-8601 timestamps compare lexicographically. Per-group (not
1005
+ // across the union) so the SPA's account grouping stays contiguous.
1006
+ const sorted = [...result.repos].sort((a, b) =>
1007
+ b.updated_at.localeCompare(a.updated_at),
1008
+ );
1009
+ for (const repo of sorted) {
1010
+ repos.push({
1011
+ ...repo,
1012
+ account_login: installation.account.login,
1013
+ installation_id: installation.id,
1014
+ });
1015
+ }
1016
+ if (result.truncated) truncated = true;
1017
+ }
787
1018
  } catch (err) {
788
1019
  return Response.json(
789
1020
  {
@@ -794,7 +1025,7 @@ export async function handleAuthGithubRepos(
794
1025
  );
795
1026
  }
796
1027
  return Response.json(
797
- { repos: result.repos, truncated: result.truncated },
1028
+ { installed: true, repos, truncated },
798
1029
  { headers: { "Access-Control-Allow-Origin": "*" } },
799
1030
  );
800
1031
  }
@@ -803,6 +1034,15 @@ export async function handleAuthGithubRepos(
803
1034
  * `POST /.parachute/mirror/auth/github/create-repo` — create a new repo on
804
1035
  * the operator's account, return the new RepoInfo. The SPA flows straight
805
1036
  * from this into the "repo selected" state.
1037
+ *
1038
+ * With the shared Parachute app this 403s by design (vault#480):
1039
+ * `POST /user/repos` needs Administration:write; the shared app is frozen
1040
+ * at Contents-only. The 403 maps to error_type "app_lacks_admin_permission"
1041
+ * with the guided-manual path (create at github.com/new → add it to the
1042
+ * installation → refresh the picker). The endpoint stays functional for
1043
+ * BYO-app operators whose app grants Administration:write — and per the
1044
+ * install docs, app-created repos auto-join the installation, so their
1045
+ * create→push flow works end-to-end.
806
1046
  */
807
1047
  export async function handleAuthGithubCreateRepo(
808
1048
  req: Request,
@@ -845,6 +1085,20 @@ export async function handleAuthGithubCreateRepo(
845
1085
  fetchImpl,
846
1086
  );
847
1087
  } catch (err) {
1088
+ if (err instanceof GitHubApiError && err.status === 403) {
1089
+ // Expected with the shared Contents-only app: POST /user/repos needs
1090
+ // Administration:write. Actionable, machine-readable mapping — the UI
1091
+ // renders the guided-manual checklist off error_type, not a string.
1092
+ return Response.json(
1093
+ {
1094
+ error: "Create not permitted for this GitHub App",
1095
+ error_type: "app_lacks_admin_permission",
1096
+ message:
1097
+ "The Parachute GitHub App can't create repositories (it only has Contents permission — creating repos needs Administration). Create it manually instead: 1) create a private, uninitialized repo at github.com/new, 2) add it to the app installation (GitHub → Settings → Applications → Configure), 3) refresh the repo list here and pick it.",
1098
+ },
1099
+ { status: 403 },
1100
+ );
1101
+ }
848
1102
  return Response.json(
849
1103
  { error: "Create failed", message: (err as Error).message ?? String(err) },
850
1104
  { status: 502 },
@@ -900,6 +1154,18 @@ export async function handleAuthGithubSelectRepo(
900
1154
  name,
901
1155
  );
902
1156
 
1157
+ // vault#483: linking implies backup intent — the choose-repo re-entry can
1158
+ // be the FIRST credential-linked action for this vault (a credential saved
1159
+ // before history-on-link existed, on a never-configured vault: the #483
1160
+ // "linked but silently inert" state). Run history-on-link here too, BEFORE
1161
+ // the Cut-3/Cut-6 steps below and mirroring handleAuthPat's ordering:
1162
+ // history on (the reload's start() resolves mirror_path so the remote
1163
+ // write below actually lands), then Cut 3 sees an enabled mirror and flips
1164
+ // auto_push, then Cut 6 fires the initial push. An explicitly-disabled
1165
+ // mirror short-circuits all of it (history stays off → maybeEnableAutoPush
1166
+ // no-ops on disabled).
1167
+ const history_enabled = await maybeEnableHistoryOnLink(manager);
1168
+
903
1169
  // Apply to the mirror dir if running. If the mirror isn't running (no
904
1170
  // mirror_path), we still consider this a success — the credentials are
905
1171
  // stored, and the URL will get applied next time the mirror starts.
@@ -933,6 +1199,7 @@ export async function handleAuthGithubSelectRepo(
933
1199
  // Echo the redacted form back so the SPA can show "pushing to <repo>".
934
1200
  // No raw token in the response.
935
1201
  remote: `https://github.com/${owner}/${name}.git`,
1202
+ history_enabled,
936
1203
  auto_push_was_already_enabled: autoPushChange.was_already_enabled,
937
1204
  auto_push_enabled: autoPushChange.auto_push_now_enabled,
938
1205
  initial_push: initialPush,
@@ -941,6 +1208,73 @@ export async function handleAuthGithubSelectRepo(
941
1208
  );
942
1209
  }
943
1210
 
1211
+ /**
1212
+ * vault#483 fix 1 — linking implies backup intent. Outcome flag for the
1213
+ * credential-save responses:
1214
+ * - `true` — history (the mirror) is on: either this link just enabled it
1215
+ * (never-configured vault) or it was already enabled.
1216
+ * - `"left_disabled"` — a mirror config exists with `enabled: false`; we
1217
+ * refuse to silently flip an explicit operator choice. The UI should
1218
+ * offer the one-click "Turn on history now?" enable.
1219
+ * - `false` — we tried to enable history but the mirror didn't come up
1220
+ * (e.g. git missing). Config intent is persisted (PUT semantics); the
1221
+ * mirror status carries the actionable error.
1222
+ */
1223
+ export type HistoryOnLink = true | false | "left_disabled";
1224
+
1225
+ /**
1226
+ * When a GitHub credential lands (device-flow grant or PAT save), turn
1227
+ * history on for a vault that has NEVER had a mirror configured — nobody
1228
+ * links GitHub to a vault for any reason other than backing it up. Aaron hit
1229
+ * this live (vault#483): pre-#440 vaults have no mirror-config file, so
1230
+ * linking stored a credential and then... nothing, with the only path out
1231
+ * buried in advanced settings.
1232
+ *
1233
+ * Consent-respecting by design:
1234
+ * - **No config file** (never configured) → enable the internal mirror
1235
+ * with the standard defaults (enabled, internal, events, auto_commit on,
1236
+ * auto_push off — the same shape #440 gives new vaults). Writes through
1237
+ * `manager.reload()` — the exact path the PUT handler uses — so persist +
1238
+ * lifecycle-start stay one code path.
1239
+ * - **Config file exists with enabled:false** → explicit operator choice;
1240
+ * do NOT flip it. Return `"left_disabled"` so the UI can offer one-click
1241
+ * enable instead.
1242
+ * - **Config file exists with enabled:true** → nothing to do.
1243
+ *
1244
+ * The file-existence check (not just the parsed read) is the load-bearing
1245
+ * distinction: an existing-but-malformed file parses to `undefined`, same as
1246
+ * absent — but it still represents operator-touched state, so we treat it as
1247
+ * "exists, not known-enabled" and leave it alone.
1248
+ */
1249
+ async function maybeEnableHistoryOnLink(
1250
+ manager: MirrorManager,
1251
+ ): Promise<HistoryOnLink> {
1252
+ const vaultName = manager.getVaultName();
1253
+ if (!existsSync(mirrorConfigPath(vaultName))) {
1254
+ try {
1255
+ const status = await manager.reload({
1256
+ ...defaultMirrorConfig(),
1257
+ enabled: true,
1258
+ });
1259
+ if (!status.enabled) {
1260
+ console.warn(
1261
+ `[mirror-auth] history-on-link: enabled the mirror config for vault "${vaultName}" but it didn't start: ${status.last_error ?? "unknown error"}`,
1262
+ );
1263
+ return false;
1264
+ }
1265
+ return true;
1266
+ } catch (err) {
1267
+ console.warn(
1268
+ `[mirror-auth] history-on-link: could not enable the mirror for vault "${vaultName}" (non-fatal): ${(err as Error).message ?? err}`,
1269
+ );
1270
+ return false;
1271
+ }
1272
+ }
1273
+ const persisted = readMirrorConfigForVault(vaultName);
1274
+ if (persisted?.enabled) return true;
1275
+ return "left_disabled";
1276
+ }
1277
+
944
1278
  /**
945
1279
  * Cut 3: when credentials save, flip `auto_push` from false → true on
946
1280
  * the persisted config. Operators wiring credentials almost certainly
@@ -1063,15 +1397,32 @@ export async function applyCredentialsToMirror(
1063
1397
  // "mode": "merge" | "replace",
1064
1398
  // "credentials": null
1065
1399
  // | { "kind": "pat", "token": "ghp_..." }
1066
- // | { "kind": "none" }
1400
+ // | { "kind": "none" },
1401
+ // "enable_sync": true // optional, DEFAULT TRUE
1067
1402
  // }
1068
1403
  //
1069
1404
  // `credentials: null` means "use the stored mirror credentials." Passing
1070
- // `{kind: "pat", token}` is the one-shot path — token doesn't get persisted.
1405
+ // `{kind: "pat", token}` is the one-shot path — token doesn't get persisted
1406
+ // for the CLONE, but IS persisted when sync is enabled (it's the push
1407
+ // credential for the now-configured mirror).
1408
+ //
1409
+ // `enable_sync` (vault#416) — DEFAULT TRUE when omitted. After a successful
1410
+ // import, auto-enable mirror push-back to the SAME repo, reusing the import's
1411
+ // credentials. Makes "import a repo" and "back up to that repo going forward"
1412
+ // one fluid flow. The UI ships a checked-by-default checkbox the operator can
1413
+ // uncheck. Edge cases (handled in `enableSyncToImportedRepo`, never fail the
1414
+ // whole import):
1415
+ // - `auth: none` (public repo, no push creds) → skip + warn (can't push
1416
+ // without a credential).
1417
+ // - A mirror already points at a DIFFERENT remote → skip + warn (don't
1418
+ // clobber the operator's existing backup target).
1419
+ // - A mirror already points at the SAME remote → no-op success.
1420
+ // - Sync setup throws → import result still returned (success);
1421
+ // `sync_enabled: false` + a warning. Import success is never lost.
1071
1422
  //
1072
1423
  // Response:
1073
1424
  // 200 { notes_imported, tags_imported, attachments_imported,
1074
- // notes_deleted?, warnings }
1425
+ // notes_deleted?, warnings, sync_enabled, sync_warning? }
1075
1426
  // 400 { error, error_type, message } — validation / not-a-vault-export
1076
1427
  // 409 { error, error_type, message } — concurrent import for this vault
1077
1428
  // 502 { error, message } — clone failed (auth, network, …)
@@ -1089,16 +1440,29 @@ export async function applyCredentialsToMirror(
1089
1440
  *
1090
1441
  * `spawnOverride` is a test seam: lets the test inject a fake git binary.
1091
1442
  * Production callers omit it; `cloneAndImport` falls back to `defaultGitSpawn`.
1443
+ *
1444
+ * `whichOverride` is a test seam for the git-presence preflight (default
1445
+ * `Bun.which` inside `cloneAndImport`). Inject a fn returning `null` to
1446
+ * exercise the git_not_installed 503 path without uninstalling git.
1447
+ *
1448
+ * `managerOverride` is a test seam for the post-import sync-enable step
1449
+ * (vault#416). Production callers omit it; the route resolves the per-vault
1450
+ * manager via `getMirrorManager(vaultName)` (same registry the auth /
1451
+ * run-now / push-now routes use). Tests inject a manager so they can assert
1452
+ * on the config it ends up with without standing up the full registry.
1092
1453
  */
1093
1454
  export async function handleMirrorImport(
1094
1455
  req: Request,
1095
1456
  vaultName: string,
1096
1457
  spawnOverride?: GitSpawn,
1458
+ whichOverride?: (cmd: string) => string | null,
1459
+ managerOverride?: MirrorManager,
1097
1460
  ): Promise<Response> {
1098
1461
  let body: {
1099
1462
  remote_url?: unknown;
1100
1463
  mode?: unknown;
1101
1464
  credentials?: unknown;
1465
+ enable_sync?: unknown;
1102
1466
  };
1103
1467
  try {
1104
1468
  body = (await req.json()) as Record<string, unknown>;
@@ -1191,6 +1555,25 @@ export async function handleMirrorImport(
1191
1555
  );
1192
1556
  }
1193
1557
 
1558
+ // vault#416: `enable_sync` defaults TRUE when omitted — the default-on UX.
1559
+ // Only a literal `false` opts out; any other type is a validation error so
1560
+ // a malformed body never silently flips the default.
1561
+ let enableSync = true;
1562
+ if ("enable_sync" in body && body.enable_sync !== undefined) {
1563
+ if (typeof body.enable_sync !== "boolean") {
1564
+ return Response.json(
1565
+ {
1566
+ error: "enable_sync invalid",
1567
+ error_type: "validation",
1568
+ field: "enable_sync",
1569
+ message: "enable_sync must be a boolean (defaults to true when omitted).",
1570
+ },
1571
+ { status: 400 },
1572
+ );
1573
+ }
1574
+ enableSync = body.enable_sync;
1575
+ }
1576
+
1194
1577
  // Resolve the target vault's store + assets dir. The route is gated on
1195
1578
  // `vault:<name>:admin`, so we trust vaultName is real by the time we
1196
1579
  // reach this code path; defensively re-resolve in case the vault was
@@ -1208,8 +1591,21 @@ export async function handleMirrorImport(
1208
1591
  store,
1209
1592
  assetsDir: assets,
1210
1593
  spawn: spawnOverride,
1594
+ which: whichOverride,
1211
1595
  });
1212
1596
  } catch (err) {
1597
+ if (err instanceof GitNotInstalledError) {
1598
+ // 503 Service Unavailable — the server isn't configured to do this
1599
+ // yet (git missing). The message tells the operator how to fix it.
1600
+ return Response.json(
1601
+ {
1602
+ error: "git not installed",
1603
+ error_type: "git_not_installed",
1604
+ message: err.message,
1605
+ },
1606
+ { status: 503 },
1607
+ );
1608
+ }
1213
1609
  if (err instanceof ImportConflictError) {
1214
1610
  return Response.json(
1215
1611
  {
@@ -1250,7 +1646,368 @@ export async function handleMirrorImport(
1250
1646
  );
1251
1647
  }
1252
1648
 
1649
+ // ---- vault#416: auto-enable sync to the imported repo (default-on) -------
1650
+ //
1651
+ // The import SUCCEEDED above. From here on, every failure is non-fatal —
1652
+ // we never lose a successful import to a sync-setup error. `result` already
1653
+ // carries `sync_enabled: false` (set by importResultFromStats); we flip it
1654
+ // true only when sync is actually wired (or already wired to this remote).
1655
+ if (enableSync) {
1656
+ try {
1657
+ const manager =
1658
+ managerOverride ?? getMirrorManager(vaultName) ?? undefined;
1659
+ const outcome = await enableSyncToImportedRepo({
1660
+ vaultName,
1661
+ remoteUrl: remote_url,
1662
+ auth,
1663
+ manager,
1664
+ });
1665
+ result.sync_enabled = outcome.sync_enabled;
1666
+ if (outcome.warning) result.sync_warning = outcome.warning;
1667
+ } catch (err) {
1668
+ // Defense-in-depth: enableSyncToImportedRepo is written to not throw
1669
+ // (it catches its own write/credential/reload errors), but a future
1670
+ // edit or an unexpected throw must NOT take down a successful import.
1671
+ const msg = redactToken((err as Error).message ?? String(err));
1672
+ console.warn(
1673
+ `[mirror-import] sync-enable threw after a successful import (non-fatal): ${msg}`,
1674
+ );
1675
+ result.sync_enabled = false;
1676
+ result.sync_warning =
1677
+ "Import succeeded, but enabling Sync failed. Set up Sync separately from the Git remote section.";
1678
+ }
1679
+ }
1680
+
1253
1681
  return Response.json(result, {
1254
1682
  headers: { "Access-Control-Allow-Origin": "*" },
1255
1683
  });
1256
1684
  }
1685
+
1686
+ // ---------------------------------------------------------------------------
1687
+ // vault#416 — sync-enable after a successful import.
1688
+ // ---------------------------------------------------------------------------
1689
+
1690
+ /**
1691
+ * After a successful import, turn the imported-from repo into a configured,
1692
+ * credential-backed, auto-pushing mirror — reusing the import's credentials.
1693
+ * This is the back half of the default-on "import → also sync back" flow.
1694
+ *
1695
+ * Reuses the SAME machinery the manual mirror-setup flow uses:
1696
+ * - `writeCredentials` to persist the push credential (per-vault, vault#399).
1697
+ * - `MirrorConfig` + `manager.reload()` to enable `auto_push` and restart
1698
+ * the lifecycle — which calls `applyCredentialsToRemote` to set `origin`
1699
+ * from the stored credentials (exactly like handleAuthPat /
1700
+ * handleAuthGithubSelectRepo do).
1701
+ *
1702
+ * Mirror location is left `internal` (vault-managed dir under the vault's
1703
+ * data dir) — the import didn't ask the operator to pick an external path,
1704
+ * and `auto_push + internal + credentials` is the supported "push to a
1705
+ * GitHub/GitLab remote" shape (see mirror-config.ts validation note). The
1706
+ * remote lives in credentials, not the config; that's why we persist
1707
+ * credentials AND flip auto_push.
1708
+ *
1709
+ * Never throws — every failure path returns `{ sync_enabled: false, warning }`.
1710
+ * The import already succeeded by the time this runs; a sync-setup error must
1711
+ * not be surfaced as an import failure.
1712
+ *
1713
+ * Edge cases (returns `sync_enabled: false` + a warning, no broken mirror
1714
+ * left behind):
1715
+ * - **No push-capable credential** — `auth: none`, OR `credentialsFile`
1716
+ * with no stored credential that covers this remote's host. Can't push
1717
+ * without a credential; skip rather than configure a mirror that would
1718
+ * just fail every push.
1719
+ * - **A mirror already targets a DIFFERENT remote** — don't clobber the
1720
+ * operator's existing backup target. Detected by comparing the existing
1721
+ * stored credential's remote host/path against the import remote.
1722
+ * - **A mirror already targets the SAME remote** — no-op success.
1723
+ */
1724
+ export async function enableSyncToImportedRepo(opts: {
1725
+ vaultName: string;
1726
+ remoteUrl: string;
1727
+ auth: ImportAuth;
1728
+ /**
1729
+ * The per-vault mirror manager. Undefined only in the boot-race window
1730
+ * where the registry factory hasn't been installed; we then skip (warn)
1731
+ * rather than persisting a half-configured mirror with no live manager.
1732
+ */
1733
+ manager: MirrorManager | undefined;
1734
+ }): Promise<{ sync_enabled: boolean; warning?: string }> {
1735
+ const { vaultName, remoteUrl, auth, manager } = opts;
1736
+
1737
+ if (!manager) {
1738
+ return {
1739
+ sync_enabled: false,
1740
+ warning:
1741
+ "Sync not enabled — the vault's mirror manager wasn't ready. Set up Sync from the Git remote section.",
1742
+ };
1743
+ }
1744
+
1745
+ // --- Resolve the push credential we'll persist for this remote. ----------
1746
+ // We need a credential that can PUSH to `remoteUrl`. The clone-time auth
1747
+ // shapes map onto push credentials as follows:
1748
+ // - pat → persist the supplied PAT against this remote.
1749
+ // - none → no push credential; cannot enable a working sync.
1750
+ // - credentialsFile → reuse the already-stored credential, but only if it
1751
+ // actually covers this remote's host (a stored PAT for
1752
+ // a different host can't push here).
1753
+ let credentialToWrite: MirrorCredentials | null = null;
1754
+
1755
+ if (auth.kind === "none") {
1756
+ return {
1757
+ sync_enabled: false,
1758
+ warning:
1759
+ "Sync not enabled — pushing changes back needs write credentials (a PAT or GitHub sign-in). Set up Sync separately to enable it.",
1760
+ };
1761
+ }
1762
+
1763
+ if (auth.kind === "pat") {
1764
+ // Persist the supplied PAT against this remote — the same x-access-token
1765
+ // embedding the manual PAT route stores, so bare `git push` works.
1766
+ const embedded = embedTokenInRemoteUrl(remoteUrl, auth.token);
1767
+ if (!embedded) {
1768
+ return {
1769
+ sync_enabled: false,
1770
+ warning:
1771
+ "Sync not enabled — the remote URL couldn't be parsed to embed the access token. Set up Sync separately from the Git remote section.",
1772
+ };
1773
+ }
1774
+ credentialToWrite = {
1775
+ ...(readCredentials(vaultName) ?? emptyCredentials()),
1776
+ active_method: "pat",
1777
+ pat: {
1778
+ token: auth.token,
1779
+ remote_url: embedded,
1780
+ label: "Imported-repo sync",
1781
+ },
1782
+ };
1783
+ }
1784
+
1785
+ // For credentialsFile we DON'T overwrite — we reuse what's already stored,
1786
+ // but must verify it covers this remote (host match). Resolve the existing
1787
+ // credential's effective remote so the conflict check below has something
1788
+ // to compare against.
1789
+ const existing = readCredentials(vaultName);
1790
+
1791
+ // --- Conflict / idempotency check against any already-configured mirror. --
1792
+ // The mirror's remote lives in credentials, not in MirrorConfig. Compare
1793
+ // the existing stored credential's remote against the import remote.
1794
+ const persistedConfig = readMirrorConfigForVault(vaultName);
1795
+ const mirrorAlreadyConfigured = !!persistedConfig && persistedConfig.enabled;
1796
+ const existingRemote = existing ? effectiveRemoteOf(existing) : null;
1797
+
1798
+ if (mirrorAlreadyConfigured && existingRemote) {
1799
+ if (sameRemote(existingRemote, remoteUrl)) {
1800
+ // Same remote — make sure auto_push is on, then no-op success. We don't
1801
+ // need to rewrite credentials (credentialsFile) or can refresh the PAT
1802
+ // (pat path) — either way the mirror already points here.
1803
+ if (auth.kind === "pat" && credentialToWrite) {
1804
+ try {
1805
+ writeCredentials(vaultName, credentialToWrite);
1806
+ } catch (err) {
1807
+ return {
1808
+ sync_enabled: false,
1809
+ warning: `Import succeeded; mirror already targets this repo but the credential refresh failed: ${redactToken((err as Error).message ?? String(err))}`,
1810
+ };
1811
+ }
1812
+ }
1813
+ return await applyEnabledAutoPush(manager);
1814
+ }
1815
+ // Different remote — don't clobber the operator's existing backup target.
1816
+ return {
1817
+ sync_enabled: false,
1818
+ warning: `Sync not enabled — this vault already syncs to a different repo (${redactRemoteUrl(existingRemote)}). Leaving your existing backup target untouched. Change it from the Git remote section if you want to switch.`,
1819
+ };
1820
+ }
1821
+
1822
+ // A mirror is already configured + enabled with an active credential, but we
1823
+ // couldn't read its remote to compare (github_oauth stores no owner/repo at
1824
+ // the credential level — its remote lives on the mirror dir's `origin`). To
1825
+ // avoid clobbering an existing GitHub-connected backup target by switching
1826
+ // `active_method` to a PAT, refuse unless the existing credential is OAuth
1827
+ // and this is the same GitHub host (then the manual select-repo flow already
1828
+ // points origin where it should; we treat that as "already set up — leave
1829
+ // it"). Conservative: don't auto-switch an existing connected mirror.
1830
+ if (
1831
+ mirrorAlreadyConfigured &&
1832
+ existing?.active_method === "github_oauth" &&
1833
+ existing.github_oauth
1834
+ ) {
1835
+ return {
1836
+ sync_enabled: false,
1837
+ warning:
1838
+ "Sync not enabled — this vault already syncs via a connected GitHub account. Leaving your existing backup target untouched. Switch it from the Git remote section if you want to point sync at this repo instead.",
1839
+ };
1840
+ }
1841
+
1842
+ // --- No conflicting mirror. Wire it up. ----------------------------------
1843
+ if (auth.kind === "credentialsFile") {
1844
+ // Reuse the stored credential — but only if it can push to this remote.
1845
+ const covers = existing && existingRemote && sameRemote(existingRemote, remoteUrl);
1846
+ const hasOauthForGithub =
1847
+ existing?.active_method === "github_oauth" &&
1848
+ !!existing.github_oauth &&
1849
+ isGithubRemote(remoteUrl);
1850
+ if (!covers && !hasOauthForGithub) {
1851
+ return {
1852
+ sync_enabled: false,
1853
+ warning:
1854
+ "Sync not enabled — no saved credential can push to this repo. Connect GitHub or paste a Personal Access Token in the Git remote section to enable Sync.",
1855
+ };
1856
+ }
1857
+ if (existing?.active_method === "github_oauth" && hasOauthForGithub) {
1858
+ // OAuth path: persist origin via the github authed URL, same as the
1859
+ // select-repo flow. Parse owner/repo from the import remote.
1860
+ const ownerRepo = parseGithubOwnerRepo(remoteUrl);
1861
+ if (!ownerRepo) {
1862
+ return {
1863
+ sync_enabled: false,
1864
+ warning:
1865
+ "Sync not enabled — couldn't parse owner/repo from the GitHub URL. Pick the repo from the Git remote section to enable Sync.",
1866
+ };
1867
+ }
1868
+ const status = manager.getStatus();
1869
+ const authedUrl = githubAuthedRemoteUrl(
1870
+ existing.github_oauth!.access_token,
1871
+ ownerRepo.owner,
1872
+ ownerRepo.repo,
1873
+ );
1874
+ if (status.mirror_path) {
1875
+ await applyToGitRemote(status.mirror_path, authedUrl);
1876
+ }
1877
+ // Stash a PAT-shaped credential so a restart re-applies the right
1878
+ // origin without needing the operator to re-pick the repo. (The OAuth
1879
+ // token works through the same x-access-token shape.) This mirrors how
1880
+ // applyCredentialsToRemote refreshes github origins on restart.
1881
+ credentialToWrite = {
1882
+ ...existing,
1883
+ active_method: "github_oauth",
1884
+ };
1885
+ }
1886
+ // covers === true → existing PAT already targets this remote; nothing to
1887
+ // re-persist. Fall through to enabling auto_push.
1888
+ }
1889
+
1890
+ if (credentialToWrite) {
1891
+ try {
1892
+ writeCredentials(vaultName, credentialToWrite);
1893
+ } catch (err) {
1894
+ return {
1895
+ sync_enabled: false,
1896
+ warning: `Import succeeded, but saving the sync credential failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
1897
+ };
1898
+ }
1899
+ }
1900
+
1901
+ return await applyEnabledAutoPush(manager);
1902
+ }
1903
+
1904
+ /**
1905
+ * Flip the mirror config to `enabled: true, auto_push: true` (internal
1906
+ * location) and reload the manager — which applies the just-persisted
1907
+ * credentials to `origin` and starts the event-driven export loop. Returns
1908
+ * the sync outcome; reload failures are non-fatal (import already succeeded).
1909
+ */
1910
+ async function applyEnabledAutoPush(
1911
+ manager: MirrorManager,
1912
+ ): Promise<{ sync_enabled: boolean; warning?: string }> {
1913
+ const current = manager.getEffectiveConfig();
1914
+ const next: MirrorConfig = {
1915
+ ...current,
1916
+ enabled: true,
1917
+ // Keep the operator's existing location if they'd set external; default
1918
+ // internal for the fresh case. Internal + credentials is the supported
1919
+ // "push to a hosted remote" shape.
1920
+ location: current.location,
1921
+ auto_push: true,
1922
+ };
1923
+ try {
1924
+ await manager.reload(next);
1925
+ } catch (err) {
1926
+ return {
1927
+ sync_enabled: false,
1928
+ warning: `Import succeeded, but enabling Sync failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
1929
+ };
1930
+ }
1931
+ // reload → start sets status.enabled iff bootstrap succeeded. If bootstrap
1932
+ // failed (e.g. git-less host, though import would've 503'd earlier), surface
1933
+ // that rather than claiming sync is on.
1934
+ const status = manager.getStatus();
1935
+ if (!status.enabled) {
1936
+ return {
1937
+ sync_enabled: false,
1938
+ warning: status.last_error
1939
+ ? `Import succeeded, but the mirror couldn't start: ${redactToken(status.last_error)}`
1940
+ : "Import succeeded, but the mirror couldn't start. Check the Git remote section.",
1941
+ };
1942
+ }
1943
+ return { sync_enabled: true };
1944
+ }
1945
+
1946
+ /**
1947
+ * Embed an x-access-token credential into an HTTPS remote URL, the same shape
1948
+ * the manual PAT route persists. Returns null when the URL can't be parsed or
1949
+ * isn't http(s) (SSH / local-path remotes can't carry a token in userinfo —
1950
+ * those rely on the operator's ssh-agent, so there's no push credential for
1951
+ * us to embed). When the URL already carries userinfo, trust it verbatim.
1952
+ */
1953
+ function embedTokenInRemoteUrl(remoteUrl: string, token: string): string | null {
1954
+ let parsed: URL;
1955
+ try {
1956
+ parsed = new URL(remoteUrl);
1957
+ } catch {
1958
+ return null;
1959
+ }
1960
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1961
+ if (parsed.username || parsed.password) return remoteUrl;
1962
+ parsed.username = "x-access-token";
1963
+ parsed.password = token;
1964
+ return parsed.toString();
1965
+ }
1966
+
1967
+ /**
1968
+ * The remote a stored credential effectively pushes to:
1969
+ * - pat → its `remote_url` (userinfo-embedded; comparison strips userinfo).
1970
+ * - github_oauth → null here (no owner/repo stored at the credential level;
1971
+ * the origin is set when a repo is picked). Callers treat null as "no
1972
+ * known remote to conflict with."
1973
+ */
1974
+ function effectiveRemoteOf(creds: MirrorCredentials): string | null {
1975
+ if (creds.active_method === "pat" && creds.pat) return creds.pat.remote_url;
1976
+ return null;
1977
+ }
1978
+
1979
+ /**
1980
+ * Compare two remote URLs ignoring userinfo (embedded tokens) + a trailing
1981
+ * `.git` + a trailing slash, case-insensitively on host. "Same repo" for the
1982
+ * clobber check. Falls back to a trimmed string compare for non-URL remotes
1983
+ * (SSH shorthand, local paths).
1984
+ */
1985
+ function sameRemote(a: string, b: string): boolean {
1986
+ const norm = (u: string): string => {
1987
+ try {
1988
+ const url = new URL(u);
1989
+ const host = url.host.toLowerCase();
1990
+ const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
1991
+ return `${url.protocol}//${host}${path}`;
1992
+ } catch {
1993
+ return u.trim().replace(/\.git$/, "").replace(/\/+$/, "");
1994
+ }
1995
+ };
1996
+ return norm(a) === norm(b);
1997
+ }
1998
+
1999
+ /** True when a remote URL is on github.com (host-exact, case-insensitive). */
2000
+ function isGithubRemote(remoteUrl: string): boolean {
2001
+ try {
2002
+ return new URL(remoteUrl).host.toLowerCase() === "github.com";
2003
+ } catch {
2004
+ return false;
2005
+ }
2006
+ }
2007
+
2008
+ /** Parse `owner` + `repo` out of a github.com HTTPS URL. Null when it doesn't match. */
2009
+ function parseGithubOwnerRepo(remoteUrl: string): { owner: string; repo: string } | null {
2010
+ const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?(?:\/)?$/i);
2011
+ if (!match) return null;
2012
+ return { owner: match[1]!, repo: match[2]! };
2013
+ }