@serviceme/devtools-core 0.2.0 → 0.2.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.
package/dist/index.mjs CHANGED
@@ -842,6 +842,11 @@ var DEFAULT_DEVICE_CODE_URL = "https://github.com/login/device/code";
842
842
  var DEFAULT_TOKEN_URL = "https://github.com/login/oauth/access_token";
843
843
  var DEFAULT_USER_URL = "https://api.github.com/user";
844
844
  var DEFAULT_SCOPE = "read:user user:email";
845
+ var DEVICE_CODE_MAX_ATTEMPTS = 3;
846
+ var DEFAULT_DEVICE_CODE_RETRY_BASE_DELAY_MS = 500;
847
+ function isTransientNetworkError(error) {
848
+ return error instanceof TypeError;
849
+ }
845
850
  var GitHubAuthProvider = class {
846
851
  constructor(config) {
847
852
  this.providerId = "github";
@@ -857,7 +862,8 @@ var GitHubAuthProvider = class {
857
862
  minPollIntervalMs: config.minPollIntervalMs ?? 1e3,
858
863
  maxPollIntervalMs: config.maxPollIntervalMs ?? 15e3,
859
864
  maxWaitMs: config.maxWaitMs,
860
- fetchImpl: config.fetchImpl ?? fetch
865
+ fetchImpl: config.fetchImpl ?? fetch,
866
+ deviceCodeRetryBaseDelayMs: config.deviceCodeRetryBaseDelayMs ?? DEFAULT_DEVICE_CODE_RETRY_BASE_DELAY_MS
861
867
  };
862
868
  }
863
869
  async requestDeviceFlow(opts) {
@@ -865,29 +871,45 @@ var GitHubAuthProvider = class {
865
871
  client_id: this.cfg.clientId,
866
872
  scope: opts?.scope ?? this.cfg.scope
867
873
  });
868
- const resp = await this.cfg.fetchImpl(this.cfg.deviceCodeUrl, {
869
- method: "POST",
870
- headers: {
871
- Accept: "application/json",
872
- "Content-Type": "application/json",
873
- "User-Agent": "serviceme-core"
874
- },
875
- body
876
- });
877
- if (!resp.ok) {
878
- throw new Error(`GitHub device-code request failed: HTTP ${resp.status}`);
879
- }
880
- const data = await resp.json();
881
- if (!data.device_code || !data.user_code || !data.verification_uri) {
882
- throw new Error("GitHub device-code response missing required fields");
874
+ let lastNetworkError;
875
+ for (let attempt = 1; attempt <= DEVICE_CODE_MAX_ATTEMPTS; attempt++) {
876
+ let resp;
877
+ try {
878
+ resp = await this.cfg.fetchImpl(this.cfg.deviceCodeUrl, {
879
+ method: "POST",
880
+ headers: {
881
+ Accept: "application/json",
882
+ "Content-Type": "application/json",
883
+ "User-Agent": "serviceme-core"
884
+ },
885
+ body
886
+ });
887
+ } catch (error) {
888
+ if (!isTransientNetworkError(error) || attempt === DEVICE_CODE_MAX_ATTEMPTS) {
889
+ throw error;
890
+ }
891
+ lastNetworkError = error;
892
+ await sleep(this.cfg.deviceCodeRetryBaseDelayMs * 2 ** (attempt - 1));
893
+ continue;
894
+ }
895
+ if (!resp.ok) {
896
+ throw new Error(
897
+ `GitHub device-code request failed: HTTP ${resp.status}`
898
+ );
899
+ }
900
+ const data = await resp.json();
901
+ if (!data.device_code || !data.user_code || !data.verification_uri) {
902
+ throw new Error("GitHub device-code response missing required fields");
903
+ }
904
+ return {
905
+ provider: this.providerId,
906
+ userCode: data.user_code,
907
+ verificationUrl: data.verification_uri,
908
+ expiresAt: Date.now() + data.expires_in * 1e3,
909
+ message: `Open ${data.verification_uri} and enter ${data.user_code}`
910
+ };
883
911
  }
884
- return {
885
- provider: this.providerId,
886
- userCode: data.user_code,
887
- verificationUrl: data.verification_uri,
888
- expiresAt: Date.now() + data.expires_in * 1e3,
889
- message: `Open ${data.verification_uri} and enter ${data.user_code}`
890
- };
912
+ throw lastNetworkError ?? new Error("GitHub device-code request failed");
891
913
  }
892
914
  async completeDeviceFlow(deviceCode, shouldContinue) {
893
915
  const start = Date.now();
@@ -905,21 +927,36 @@ var GitHubAuthProvider = class {
905
927
  if (shouldContinue && !shouldContinue()) {
906
928
  throw new Error("GitHub device flow cancelled by caller");
907
929
  }
908
- const resp = await this.cfg.fetchImpl(this.cfg.tokenUrl, {
909
- method: "POST",
910
- headers: {
911
- Accept: "application/json",
912
- "Content-Type": "application/json",
913
- "User-Agent": "serviceme-core"
914
- },
915
- body: JSON.stringify({
916
- client_id: this.cfg.clientId,
917
- device_code: deviceCode,
918
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
919
- })
920
- });
930
+ let resp;
931
+ try {
932
+ resp = await this.cfg.fetchImpl(this.cfg.tokenUrl, {
933
+ method: "POST",
934
+ headers: {
935
+ Accept: "application/json",
936
+ "Content-Type": "application/json",
937
+ "User-Agent": "serviceme-core"
938
+ },
939
+ body: JSON.stringify({
940
+ client_id: this.cfg.clientId,
941
+ device_code: deviceCode,
942
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
943
+ })
944
+ });
945
+ } catch (error) {
946
+ if (!isTransientNetworkError(error)) {
947
+ throw error;
948
+ }
949
+ pollIntervalMs = Math.min(
950
+ Math.round(pollIntervalMs * 1.5),
951
+ this.cfg.maxPollIntervalMs
952
+ );
953
+ continue;
954
+ }
921
955
  if (!resp.ok) {
922
- pollIntervalMs = Math.min(Math.round(pollIntervalMs * 1.5), this.cfg.maxPollIntervalMs);
956
+ pollIntervalMs = Math.min(
957
+ Math.round(pollIntervalMs * 1.5),
958
+ this.cfg.maxPollIntervalMs
959
+ );
923
960
  continue;
924
961
  }
925
962
  const data = await resp.json();
@@ -945,7 +982,9 @@ var GitHubAuthProvider = class {
945
982
  if (data.error === "slow_down") {
946
983
  consecutiveSlowDown++;
947
984
  pollIntervalMs = Math.min(
948
- Math.round(pollIntervalMs * 1.5 + Math.min(consecutiveSlowDown * 500, 2e3)),
985
+ Math.round(
986
+ pollIntervalMs * 1.5 + Math.min(consecutiveSlowDown * 500, 2e3)
987
+ ),
949
988
  this.cfg.maxPollIntervalMs
950
989
  );
951
990
  continue;
@@ -981,7 +1020,9 @@ var GitHubAuthProvider = class {
981
1020
  }
982
1021
  const data = await resp.json();
983
1022
  if (!data.access_token) {
984
- throw new Error(`GitHub refresh failed: ${data.error ?? "no_access_token"}`);
1023
+ throw new Error(
1024
+ `GitHub refresh failed: ${data.error ?? "no_access_token"}`
1025
+ );
985
1026
  }
986
1027
  const rawUser = await this.fetchGitHubUser(data.access_token);
987
1028
  const email = await this.resolveEmail(data.access_token, rawUser);
@@ -1051,13 +1092,16 @@ var GitHubAuthProvider = class {
1051
1092
  */
1052
1093
  async resolveEmail(token, user) {
1053
1094
  if (user.email) return user.email;
1054
- const emailsResp = await this.cfg.fetchImpl("https://api.github.com/user/emails", {
1055
- headers: {
1056
- Accept: "application/vnd.github+json",
1057
- Authorization: `Bearer ${token}`,
1058
- "User-Agent": "serviceme-core"
1095
+ const emailsResp = await this.cfg.fetchImpl(
1096
+ "https://api.github.com/user/emails",
1097
+ {
1098
+ headers: {
1099
+ Accept: "application/vnd.github+json",
1100
+ Authorization: `Bearer ${token}`,
1101
+ "User-Agent": "serviceme-core"
1102
+ }
1059
1103
  }
1060
- });
1104
+ );
1061
1105
  if (!emailsResp.ok) {
1062
1106
  return null;
1063
1107
  }
@@ -2098,14 +2142,7 @@ function extractFrontmatter(raw) {
2098
2142
  }
2099
2143
  return { data, body };
2100
2144
  }
2101
- var SKIP_DIRS = /* @__PURE__ */ new Set([
2102
- ".git",
2103
- "node_modules",
2104
- ".vscode",
2105
- "dist",
2106
- "build",
2107
- "out"
2108
- ]);
2145
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".vscode", "dist", "build", "out"]);
2109
2146
  var SKIP_TOP_LEVEL_SCAN_DIRS = /* @__PURE__ */ new Set([...SKIP_DIRS, "plugins"]);
2110
2147
 
2111
2148
  // src/drafts/index.ts
@@ -3376,7 +3413,9 @@ function idFromUrl(url) {
3376
3413
  const last = trimmed.split("/").pop() ?? "";
3377
3414
  const safe = last.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
3378
3415
  if (!SAFE_REPO_ID_PATTERN.test(safe)) {
3379
- throw new InvalidRepoUrlError(`cannot derive a valid repo id from url: ${url}`);
3416
+ throw new InvalidRepoUrlError(
3417
+ `cannot derive a valid repo id from url: ${url}`
3418
+ );
3380
3419
  }
3381
3420
  return safe;
3382
3421
  }
@@ -3418,7 +3457,10 @@ var RepoManager = class {
3418
3457
  if (!repo.enabled) continue;
3419
3458
  const localPath = getRepoDir(repo.id);
3420
3459
  const exists = await this.pathExists(localPath);
3421
- if (exists) continue;
3460
+ if (exists) {
3461
+ if (await this.isValidGitRepo(localPath)) continue;
3462
+ await fs8.rm(localPath, { recursive: true, force: true });
3463
+ }
3422
3464
  try {
3423
3465
  if (!this.skipClone) {
3424
3466
  await fs8.mkdir(path11.dirname(localPath), { recursive: true });
@@ -3453,7 +3495,10 @@ var RepoManager = class {
3453
3495
  if (!repo) throw new RepoNotInStoreError(repoId);
3454
3496
  const localPath = getRepoDir(repoId);
3455
3497
  const exists = await this.pathExists(localPath);
3456
- if (!exists) {
3498
+ if (exists && !await this.isValidGitRepo(localPath)) {
3499
+ await fs8.rm(localPath, { recursive: true, force: true });
3500
+ }
3501
+ if (!exists || !await this.pathExists(localPath)) {
3457
3502
  await fs8.mkdir(path11.dirname(localPath), { recursive: true });
3458
3503
  if (!this.skipClone) {
3459
3504
  await this.git.clone(repoId, repo.url, localPath);
@@ -3519,7 +3564,9 @@ var RepoManager = class {
3519
3564
  async addUserRepo(input) {
3520
3565
  const url = input.url.trim();
3521
3566
  if (!/^https?:\/\//.test(url) && !url.startsWith("git@")) {
3522
- throw new InvalidRepoUrlError(`expected https:// or git@ URL, got: ${url}`);
3567
+ throw new InvalidRepoUrlError(
3568
+ `expected https:// or git@ URL, got: ${url}`
3569
+ );
3523
3570
  }
3524
3571
  const id = idFromUrl(url);
3525
3572
  assertSafeRepoId(id);
@@ -3601,6 +3648,14 @@ var RepoManager = class {
3601
3648
  async ensureHome() {
3602
3649
  await fs8.mkdir(getServicemeHome(), { recursive: true });
3603
3650
  }
3651
+ /**
3652
+ * Returns `true` when `p` contains a `.git` entry — i.e. it is an
3653
+ * initialised git repository and safe to `pull`. Empty directories
3654
+ * (e.g. from an interrupted clone) return `false`.
3655
+ */
3656
+ async isValidGitRepo(p) {
3657
+ return this.pathExists(path11.join(p, ".git"));
3658
+ }
3604
3659
  async pathExists(p) {
3605
3660
  try {
3606
3661
  await fs8.stat(p);
@@ -5779,7 +5834,10 @@ function readV1Config(v1Path) {
5779
5834
  try {
5780
5835
  raw = fs19.readFileSync(v1Path, "utf-8");
5781
5836
  } catch (err) {
5782
- return { ok: false, error: `read failed: ${err instanceof Error ? err.message : String(err)}` };
5837
+ return {
5838
+ ok: false,
5839
+ error: `read failed: ${err instanceof Error ? err.message : String(err)}`
5840
+ };
5783
5841
  }
5784
5842
  let parsed;
5785
5843
  try {
@@ -5834,79 +5892,77 @@ function disambiguateName(task, existingNames, workspaceName) {
5834
5892
  existingNames.add(candidate);
5835
5893
  return { ...task, name: candidate };
5836
5894
  }
5837
- var MigrateToGlobal = class {
5838
- /**
5839
- * Run the migration. Idempotent: each v1 file is deleted after processing,
5840
- * so re-running this method on a partially-migrated state is safe (the
5841
- * already-migrated files no longer exist; only the remainder is processed).
5842
- */
5843
- static async run(options) {
5844
- const globalConfigPath = options.globalConfigPath ?? getScheduledTasksConfigPath();
5845
- const migrationFailuresPath = options.migrationFailuresPath ?? getMigrationFailuresPath();
5846
- const probe = options.probe ?? defaultProbe;
5847
- const existing = readJsonFile(globalConfigPath);
5848
- const baseConfig = existing && existing.version === 2 ? existing : { version: 2, tasks: [] };
5849
- const existingNames = new Set(baseConfig.tasks.map((t) => t.name));
5850
- const priorFailures = readJsonFile(migrationFailuresPath) ?? [];
5851
- const failures = [...priorFailures];
5852
- let migrated = 0;
5853
- const conflicts = [];
5854
- const issues = [];
5855
- for (const workspacePath of options.workspacePaths) {
5856
- const v1Path = path20.join(workspacePath, WORKSPACE_DIR, V1_FILENAME);
5857
- if (!fs19.existsSync(v1Path)) continue;
5858
- const v1 = readV1Config(v1Path);
5859
- if (!v1.ok) {
5860
- failures.push({
5861
- workspacePath,
5862
- v1Path,
5863
- error: v1.error,
5864
- at: (/* @__PURE__ */ new Date()).toISOString()
5865
- });
5866
- safeDelete(v1Path);
5867
- continue;
5868
- }
5869
- let workspaceRef;
5870
- try {
5871
- workspaceRef = await probe(workspacePath);
5872
- } catch (err) {
5873
- failures.push({
5874
- workspacePath,
5875
- v1Path,
5876
- error: `probe failed: ${err instanceof Error ? err.message : String(err)}`,
5877
- at: (/* @__PURE__ */ new Date()).toISOString()
5878
- });
5879
- safeDelete(v1Path);
5880
- continue;
5881
- }
5882
- const { config, issues: taskIssues } = migrateV1ToV22(v1.config, workspaceRef);
5883
- issues.push(...taskIssues);
5884
- const newTasks = config.tasks.map((t) => {
5885
- if (existingNames.has(t.name)) {
5886
- conflicts.push(t.name);
5887
- return disambiguateName(t, existingNames, workspaceRef.name);
5888
- }
5889
- existingNames.add(t.name);
5890
- return t;
5895
+ async function migrateToGlobal(options) {
5896
+ const globalConfigPath = options.globalConfigPath ?? getScheduledTasksConfigPath();
5897
+ const migrationFailuresPath = options.migrationFailuresPath ?? getMigrationFailuresPath();
5898
+ const probe = options.probe ?? defaultProbe;
5899
+ const existing = readJsonFile(globalConfigPath);
5900
+ const baseConfig = existing && existing.version === 2 ? existing : { version: 2, tasks: [] };
5901
+ const existingNames = new Set(baseConfig.tasks.map((t) => t.name));
5902
+ const priorFailures = readJsonFile(migrationFailuresPath) ?? [];
5903
+ const failures = [...priorFailures];
5904
+ let migrated = 0;
5905
+ const conflicts = [];
5906
+ const issues = [];
5907
+ for (const workspacePath of options.workspacePaths) {
5908
+ const v1Path = path20.join(workspacePath, WORKSPACE_DIR, V1_FILENAME);
5909
+ if (!fs19.existsSync(v1Path)) continue;
5910
+ const v1 = readV1Config(v1Path);
5911
+ if (!v1.ok) {
5912
+ failures.push({
5913
+ workspacePath,
5914
+ v1Path,
5915
+ error: v1.error,
5916
+ at: (/* @__PURE__ */ new Date()).toISOString()
5891
5917
  });
5892
- baseConfig.tasks.push(...newTasks);
5893
- migrated += 1;
5894
5918
  safeDelete(v1Path);
5919
+ continue;
5895
5920
  }
5896
- if (migrated > 0) {
5897
- ensureDir(globalConfigPath);
5898
- const tmp = `${globalConfigPath}.tmp`;
5899
- fs19.writeFileSync(tmp, JSON.stringify(baseConfig, null, " "), "utf-8");
5900
- fs19.renameSync(tmp, globalConfigPath);
5901
- }
5902
- if (failures.length > priorFailures.length) {
5903
- writeJsonFile(migrationFailuresPath, failures);
5904
- } else if (failures.length === 0 && priorFailures.length > 0) {
5905
- safeDelete(migrationFailuresPath);
5921
+ let workspaceRef;
5922
+ try {
5923
+ workspaceRef = await probe(workspacePath);
5924
+ } catch (err) {
5925
+ failures.push({
5926
+ workspacePath,
5927
+ v1Path,
5928
+ error: `probe failed: ${err instanceof Error ? err.message : String(err)}`,
5929
+ at: (/* @__PURE__ */ new Date()).toISOString()
5930
+ });
5931
+ safeDelete(v1Path);
5932
+ continue;
5906
5933
  }
5907
- return { migrated, failed: failures.length - priorFailures.length, conflicts, issues };
5934
+ const { config, issues: taskIssues } = migrateV1ToV22(v1.config, workspaceRef);
5935
+ issues.push(...taskIssues);
5936
+ const newTasks = config.tasks.map((t) => {
5937
+ if (existingNames.has(t.name)) {
5938
+ conflicts.push(t.name);
5939
+ return disambiguateName(t, existingNames, workspaceRef.name);
5940
+ }
5941
+ existingNames.add(t.name);
5942
+ return t;
5943
+ });
5944
+ baseConfig.tasks.push(...newTasks);
5945
+ migrated += 1;
5946
+ safeDelete(v1Path);
5908
5947
  }
5909
- };
5948
+ if (migrated > 0) {
5949
+ ensureDir(globalConfigPath);
5950
+ const tmp = `${globalConfigPath}.tmp`;
5951
+ fs19.writeFileSync(tmp, JSON.stringify(baseConfig, null, " "), "utf-8");
5952
+ fs19.renameSync(tmp, globalConfigPath);
5953
+ }
5954
+ if (failures.length > priorFailures.length) {
5955
+ writeJsonFile(migrationFailuresPath, failures);
5956
+ } else if (failures.length === 0 && priorFailures.length > 0) {
5957
+ safeDelete(migrationFailuresPath);
5958
+ }
5959
+ return {
5960
+ migrated,
5961
+ failed: failures.length - priorFailures.length,
5962
+ conflicts,
5963
+ issues
5964
+ };
5965
+ }
5910
5966
 
5911
5967
  // src/scheduled-tasks/workspace-probe/WorkspaceProbe.ts
5912
5968
  import { spawn as spawn5 } from "child_process";
@@ -6808,7 +6864,6 @@ export {
6808
6864
  MIGRATION_FAILURES_FILENAME,
6809
6865
  MicrosoftAuthProvider,
6810
6866
  MicrosoftProviderNotHostedError,
6811
- MigrateToGlobal,
6812
6867
  NodeGitSpawner,
6813
6868
  PROFILES_JSON_FILENAME,
6814
6869
  PidManager,
@@ -6907,6 +6962,7 @@ export {
6907
6962
  isUserRepo,
6908
6963
  matchesCron,
6909
6964
  mergeWithDefaults,
6965
+ migrateToGlobal,
6910
6966
  moveFiles,
6911
6967
  narrowRepoConfig,
6912
6968
  noopLogger,