@mytegroupinc/myte-core 0.0.55 → 0.0.57

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.
@@ -0,0 +1,378 @@
1
+ "use strict";
2
+
3
+ // Project-generic build harness helpers. These functions deliberately work on
4
+ // the public bootstrap snapshot only; they never mutate Myte state.
5
+
6
+ function asText(value) {
7
+ const text = String(value ?? "").trim();
8
+ return text || "";
9
+ }
10
+
11
+ function asList(value) {
12
+ if (Array.isArray(value)) return value.filter((item) => item !== null && item !== undefined);
13
+ if (value === null || value === undefined || value === "") return [];
14
+ return [value];
15
+ }
16
+
17
+ function uniqueTexts(values) {
18
+ const seen = new Set();
19
+ const result = [];
20
+ for (const value of asList(values)) {
21
+ const text = typeof value === "object"
22
+ ? asText(value.name || value.label || value.title || value.id || value.value)
23
+ : asText(value);
24
+ if (!text || seen.has(text.toLowerCase())) continue;
25
+ seen.add(text.toLowerCase());
26
+ result.push(text);
27
+ }
28
+ return result;
29
+ }
30
+
31
+ function normalizeFeature(feature, index) {
32
+ if (!feature || typeof feature !== "object") return null;
33
+ const id = asText(feature.feature_id || feature.id || feature.key || `F${String(index + 1).padStart(3, "0")}`);
34
+ const title = asText(feature.title || feature.name || feature.label || id);
35
+ const dependencies = uniqueTexts(feature.dependencies || feature.depends_on || feature.dependencies_on);
36
+ const roles = uniqueTexts(feature.roles || feature.role || feature.stakeholders);
37
+ const entities = uniqueTexts(feature.entities || feature.related_entities || feature.domain_entities);
38
+ return {
39
+ feature_id: id,
40
+ title,
41
+ ...(asText(feature.description) ? { description: asText(feature.description) } : {}),
42
+ ...(roles.length ? { roles } : {}),
43
+ ...(entities.length ? { entities } : {}),
44
+ ...(dependencies.length ? { dependencies } : {}),
45
+ };
46
+ }
47
+
48
+ function normalizeEntityGraph(raw) {
49
+ if (!raw || typeof raw !== "object") return { entities: [], relationships: [] };
50
+ const entities = [];
51
+ const seen = new Set();
52
+ for (const item of asList(raw.entities || raw.nodes)) {
53
+ const entity = typeof item === "object"
54
+ ? { id: asText(item.id || item.name || item.label), label: asText(item.label || item.name || item.id) }
55
+ : { id: asText(item), label: asText(item) };
56
+ if (!entity.id || seen.has(entity.id.toLowerCase())) continue;
57
+ seen.add(entity.id.toLowerCase());
58
+ entities.push(entity);
59
+ }
60
+ const relationships = [];
61
+ const edgeSeen = new Set();
62
+ for (const edge of asList(raw.edges || raw.relationships)) {
63
+ if (!edge || typeof edge !== "object") continue;
64
+ const source = asText(edge.source || edge.from || edge.parent);
65
+ const target = asText(edge.target || edge.to || edge.child);
66
+ if (!source || !target) continue;
67
+ const key = `${source.toLowerCase()}|${target.toLowerCase()}`;
68
+ if (edgeSeen.has(key)) continue;
69
+ edgeSeen.add(key);
70
+ relationships.push({
71
+ source,
72
+ target,
73
+ ...(asText(edge.reason || edge.description) ? { reason: asText(edge.reason || edge.description) } : {}),
74
+ });
75
+ }
76
+ return { entities, relationships };
77
+ }
78
+
79
+ function inferRepositoryRole(name, index) {
80
+ const value = asText(name).toLowerCase();
81
+ if (/web|front|next|ui|client/.test(value)) return "web";
82
+ if (/api|back|server|fastapi|flask|django|python/.test(value)) return "api";
83
+ return index === 0 ? "api" : index === 1 ? "web" : "unknown";
84
+ }
85
+
86
+ function normalizeIntent(snapshot) {
87
+ const intent = snapshot?.intent || snapshot?.topology || {};
88
+ const rawFeatures = intent.features || snapshot?.features || [];
89
+ const features = asList(rawFeatures).map(normalizeFeature).filter(Boolean);
90
+ const graph = normalizeEntityGraph(intent.entity_graph || intent.entityGraph || snapshot?.entity_graph);
91
+ const roles = uniqueTexts(intent.roles || intent.stakeholders || features.flatMap((item) => item.roles || []));
92
+ const workflows = asList(intent.workflows || snapshot?.workflows)
93
+ .filter((item) => item && typeof item === "object")
94
+ .map((item, index) => ({
95
+ workflow_id: asText(item.workflow_id || item.id || item.key || `W${String(index + 1).padStart(3, "0")}`),
96
+ title: asText(item.title || item.name || item.label || `Workflow ${index + 1}`),
97
+ ...(asText(item.description) ? { description: asText(item.description) } : {}),
98
+ roles: uniqueTexts(item.roles || item.stakeholders),
99
+ entities: uniqueTexts(item.entities || item.related_entities),
100
+ features: uniqueTexts(item.features || item.feature_ids),
101
+ }));
102
+ return {
103
+ roles,
104
+ features,
105
+ entity_graph: graph,
106
+ workflows,
107
+ source: intent.features || intent.entity_graph || intent.workflows ? "api_intent" : "mission_snapshot_only",
108
+ };
109
+ }
110
+
111
+ function missionStatusCounts(missions) {
112
+ const counts = {};
113
+ for (const mission of asList(missions)) {
114
+ const status = asText(mission?.status || "Unknown") || "Unknown";
115
+ counts[status] = (counts[status] || 0) + 1;
116
+ }
117
+ return counts;
118
+ }
119
+
120
+ function missionCoverage(missions) {
121
+ const active = asList(missions).filter((mission) => String(mission?.is_archived || "false") !== "true");
122
+ const ids = new Set(active.map((mission) => asText(mission?.mission_id || mission?.id)).filter(Boolean));
123
+ const missingAcceptance = active.filter((mission) => !asList(mission?.acceptance_criteria || mission?.acceptanceCriteria).length)
124
+ .map((mission) => asText(mission?.mission_id || mission?.id)).filter(Boolean);
125
+ const missingTests = active.filter((mission) => !mission?.test_cases || typeof mission.test_cases !== "object")
126
+ .map((mission) => asText(mission?.mission_id || mission?.id)).filter(Boolean);
127
+ const missingDependencies = active.filter((mission) => !asList(mission?.depends_on).length)
128
+ .map((mission) => asText(mission?.mission_id || mission?.id)).filter(Boolean);
129
+ const orphaned = active.filter((mission) => !asText(mission?.story_id || mission?.epic_id || mission?.phase_id))
130
+ .map((mission) => asText(mission?.mission_id || mission?.id)).filter(Boolean);
131
+ return {
132
+ active_missions: active.length,
133
+ mission_ids: [...ids],
134
+ status_counts: missionStatusCounts(active),
135
+ missing_acceptance_criteria: missingAcceptance,
136
+ missing_test_cases: missingTests,
137
+ missing_dependency_metadata: missingDependencies,
138
+ orphaned_missions: orphaned,
139
+ };
140
+ }
141
+
142
+ function buildTopology(snapshot, local = {}) {
143
+ const repoNames = Array.isArray(snapshot?.repo_names) ? snapshot.repo_names.map(asText).filter(Boolean) : [];
144
+ const repoBindings = Array.isArray(snapshot?.repo_bindings) ? snapshot.repo_bindings : [];
145
+ const repositories = repoNames.map((name, index) => {
146
+ const binding = repoBindings.find((item) => asText(item?.client_repo_name || item?.canonical_repo_name || item?.repo_name) === name);
147
+ return {
148
+ name,
149
+ role: asText(binding?.role) || inferRepositoryRole(name, index),
150
+ ...(binding ? { binding: { role: asText(binding.role), path: asText(binding.path || binding.client_repo_name || name) } } : {}),
151
+ };
152
+ });
153
+ const intent = normalizeIntent(snapshot);
154
+ const missions = Array.isArray(snapshot?.missions) ? snapshot.missions : [];
155
+ const groups = new Map();
156
+ const activeMissions = missions.filter((mission) => String(mission?.is_archived || "false") !== "true");
157
+ for (const mission of activeMissions) {
158
+ const grouping = mission?.workflow_id
159
+ ? "workflow"
160
+ : mission?.domain
161
+ ? "domain"
162
+ : mission?.category
163
+ ? "category_fallback"
164
+ : mission?.epic_title
165
+ ? "epic_fallback"
166
+ : mission?.phase_name
167
+ ? "phase_fallback"
168
+ : "unclassified_fallback";
169
+ const key = asText(mission?.workflow_id || mission?.domain || mission?.category || mission?.epic_title || mission?.phase_name || "unclassified");
170
+ if (!groups.has(key)) groups.set(key, { missionIds: [], grouping });
171
+ const missionId = asText(mission?.mission_id || mission?.id);
172
+ if (missionId) groups.get(key).missionIds.push(missionId);
173
+ }
174
+ return {
175
+ schema_version: 1,
176
+ generated_at: new Date().toISOString(),
177
+ source_snapshot_hash: snapshot?.snapshot_hash || null,
178
+ project: snapshot?.project ? {
179
+ id: asText(snapshot.project.id || snapshot.project.project_id),
180
+ title: asText(snapshot.project.title || snapshot.project.name),
181
+ description: asText(snapshot.project.description),
182
+ } : null,
183
+ repositories,
184
+ intent,
185
+ mission_groups: [...groups.entries()].map(([group, details]) => ({
186
+ group,
187
+ mission_ids: details.missionIds,
188
+ grouping: details.grouping,
189
+ source: details.grouping.endsWith("_fallback") ? "fallback" : "bootstrap_metadata",
190
+ })),
191
+ mission_coverage: missionCoverage(missions),
192
+ local_repositories: {
193
+ mode: asText(local.mode) || "unknown",
194
+ found: Array.isArray(local.found) ? local.found : [],
195
+ missing: Array.isArray(local.missing) ? local.missing : [],
196
+ },
197
+ };
198
+ }
199
+
200
+ function buildBuildHarnessMarkdown(topology) {
201
+ const projectTitle = asText(topology?.project?.title) || "Myte project";
202
+ const groups = asList(topology?.mission_groups).map((item) => `- ${item.group}: ${(item.mission_ids || []).join(", ") || "none"}`).join("\n") || "- No mission groups were returned.";
203
+ return [
204
+ "# Myte Build Harness",
205
+ "",
206
+ `Generated for **${projectTitle}** by \`myte bootstrap\`.`,
207
+ "This is a project execution contract. It does not grant permissions or replace the API, repository, or deployment rules.",
208
+ "",
209
+ "## Global end goal",
210
+ "",
211
+ "Reach a coherent, tested, user-observable product state in which every active mission is complete and the final server bootstrap, QA/QC evidence, repository gates, and semantic review agree.",
212
+ "",
213
+ "## Harness rules",
214
+ "",
215
+ "1. Bootstrap before every execution cycle; never rely on remembered mission state.",
216
+ "2. Discover topology before implementation: roles, domains, workflows, entities, relationships, repositories, and mission dependencies.",
217
+ "3. Group work by product workflow and domain, not only by status or frontend/backend labels.",
218
+ "4. Map each mission to the owning frontend route/page/state/service/type/test and backend route/service/persistence/task/test surfaces.",
219
+ "5. Tie acceptance criteria to observable behavior, including loading, empty, error, denied, stale, and recovery states.",
220
+ "6. Update mission status only through the Myte API/CLI. Re-bootstrap after every status mutation.",
221
+ "7. Run focused tests first, then the owning repository gate before marking a group complete.",
222
+ "8. A passing build is insufficient: perform a semantic/UI review against the workflow and topology.",
223
+ "9. Record deviations when implementation changes the intended topology, ownership, workflow, or contract.",
224
+ "10. Finish only when the final bootstrap and local evidence agree that all active missions are complete.",
225
+ "",
226
+ "## Mission groups from bootstrap",
227
+ "",
228
+ groups,
229
+ "",
230
+ "## Required execution cycle",
231
+ "",
232
+ "1. Run `myte bootstrap --json`.",
233
+ "2. Read `data/build-topology.yml`, mission cards, `AgentsMyteAPI.md`, and repository `AGENTS.md` files.",
234
+ "3. Select one dependency-safe workflow group.",
235
+ "4. Inspect the complete user path and both repository ownership surfaces.",
236
+ "5. Add or update focused tests before implementation where acceptance behavior is missing.",
237
+ "6. Implement the smallest coherent change across the owning repositories.",
238
+ "7. Run focused tests and the repository quality gate.",
239
+ "8. Perform semantic/UI review and record any deviation.",
240
+ "9. Update the relevant mission status through `myte mission status`.",
241
+ "10. Run `myte bootstrap --json` again and verify the changed state.",
242
+ "",
243
+ "## Completion gate",
244
+ "",
245
+ "Run `myte build verify --json`. Completion requires zero active incomplete missions, no orphan missions, no missing acceptance criteria or test cases, no unresolved actionable suggestion threads, QA/QC evidence for applicable work, passing repository gates, and a recorded semantic review.",
246
+ "",
247
+ "## Evidence and deviations",
248
+ "",
249
+ "Keep final evidence and deviations in the Command Center. Do not put credentials, customer data, raw prompts, or secret values in the harness.",
250
+ "",
251
+ ].join("\n");
252
+ }
253
+
254
+ function normalizeMissionId(value) {
255
+ return asText(value).toLowerCase();
256
+ }
257
+
258
+ function qaqcMissionRecords(qaqc) {
259
+ if (Array.isArray(qaqc)) return qaqc;
260
+ if (!qaqc || typeof qaqc !== "object") return [];
261
+ return asList(qaqc.active_missions || qaqc.missions);
262
+ }
263
+
264
+ function qaqcMissionCoverage(topology, qaqc) {
265
+ const expectedMissionIds = uniqueTexts(topology?.mission_coverage?.mission_ids)
266
+ .map((missionId) => ({ key: normalizeMissionId(missionId), value: missionId }));
267
+ if (Number(topology?.mission_coverage?.active_missions || 0) > 0 && expectedMissionIds.length === 0) {
268
+ return {
269
+ expected_missions: [],
270
+ covered_missions: [],
271
+ missing_missions: [],
272
+ not_run_missions: [],
273
+ failed_missions: [],
274
+ pending_missions: [],
275
+ record_count: qaqcMissionRecords(qaqc).length,
276
+ topology_mission_ids_missing: true,
277
+ ok: false,
278
+ };
279
+ }
280
+ const expectedByKey = new Map(expectedMissionIds.map((item) => [item.key, item.value]));
281
+ const records = qaqcMissionRecords(qaqc);
282
+ const covered = new Set();
283
+ const missingRun = [];
284
+ const failed = [];
285
+ const pending = [];
286
+ const invalid = [];
287
+
288
+ for (const record of records) {
289
+ if (!record || typeof record !== "object") continue;
290
+ const missionId = asText(record.mission_id || record.id);
291
+ const key = normalizeMissionId(missionId);
292
+ if (!key || !expectedByKey.has(key)) continue;
293
+ const summary = record.qaqc || record.qa_qc_results;
294
+ if (!summary || typeof summary !== "object") {
295
+ invalid.push(missionId);
296
+ continue;
297
+ }
298
+ const status = asText(summary.status).toLowerCase();
299
+ const resultCounts = summary.result_counts && typeof summary.result_counts === "object"
300
+ ? summary.result_counts
301
+ : {};
302
+ const failureCount = Number(resultCounts.fail || 0);
303
+ const pendingCount = Number(resultCounts.pending || 0);
304
+ if (["not_run", "unknown", "pending", "queued", "running", ""].includes(status)) {
305
+ missingRun.push(missionId);
306
+ continue;
307
+ }
308
+ covered.add(key);
309
+ if (["failed", "error"].includes(status) || summary.has_error === true || failureCount > 0) {
310
+ failed.push(missionId);
311
+ }
312
+ if (pendingCount > 0) pending.push(missionId);
313
+ }
314
+
315
+ const missing = expectedMissionIds
316
+ .filter((item) => !covered.has(item.key))
317
+ .map((item) => item.value);
318
+ const missingCoverage = [...new Set([...missing, ...missingRun, ...invalid])];
319
+ return {
320
+ expected_missions: expectedMissionIds.map((item) => item.value),
321
+ covered_missions: expectedMissionIds.filter((item) => covered.has(item.key)).map((item) => item.value),
322
+ missing_missions: missingCoverage,
323
+ not_run_missions: [...new Set(missingRun)],
324
+ failed_missions: [...new Set(failed)],
325
+ pending_missions: [...new Set(pending)],
326
+ record_count: records.length,
327
+ ok: missingCoverage.length === 0 && failed.length === 0 && pending.length === 0,
328
+ };
329
+ }
330
+
331
+ function verifyTopology(topology, options = {}) {
332
+ const errors = [];
333
+ const warnings = [];
334
+ const coverage = topology?.mission_coverage || {};
335
+ if (!topology?.project?.id) errors.push("project id is missing");
336
+ if (!Array.isArray(topology?.repositories) || topology.repositories.length === 0) warnings.push("no configured repositories were returned");
337
+ for (const key of ["missing_acceptance_criteria", "missing_test_cases", "orphaned_missions"]) {
338
+ const values = Array.isArray(coverage[key]) ? coverage[key] : [];
339
+ if (values.length) errors.push(`${key}: ${values.join(", ")}`);
340
+ }
341
+ if (coverage.active_missions > 0 && !Object.keys(coverage.status_counts || {}).length) errors.push("mission status counts are missing");
342
+ if (topology?.intent?.source === "mission_snapshot_only") warnings.push("API intent topology was not returned; workflow/domain grouping is fallback-only");
343
+ if (topology?.intent?.source !== "mission_snapshot_only" && !(topology?.intent?.workflows || []).length) {
344
+ warnings.push("no explicit workflows were returned by bootstrap; validate workflow boundaries from the feature and entity graph");
345
+ }
346
+ if ((topology?.mission_groups || []).some((group) => group.source === "fallback")) {
347
+ warnings.push("one or more mission groups use fallback metadata; confirm the grouping matches a real product workflow");
348
+ }
349
+ if (!(options.semanticReview === true)) errors.push("semantic/UI review evidence is missing");
350
+ if (!(options.repositoryGates === true)) errors.push("repository gate evidence is missing");
351
+ if (coverage.active_missions > 0 && !(options.qaqcEvidence === true)) {
352
+ errors.push("QA/QC evidence is missing for active missions");
353
+ }
354
+ if (coverage.active_missions > 0 && options.qaqcEvidence === true && options.qaqcCoverage?.ok !== true) {
355
+ const qaqcCoverage = options.qaqcCoverage || {};
356
+ if (qaqcCoverage.topology_mission_ids_missing === true) {
357
+ errors.push("QA/QC coverage cannot be checked because mission ids are missing from the bootstrap topology");
358
+ }
359
+ if (qaqcCoverage.missing_missions?.length) {
360
+ errors.push(`QA/QC coverage is missing or not terminal for missions: ${qaqcCoverage.missing_missions.join(", ")}`);
361
+ }
362
+ if (qaqcCoverage.failed_missions?.length) {
363
+ errors.push(`QA/QC reports failures for missions: ${qaqcCoverage.failed_missions.join(", ")}`);
364
+ }
365
+ if (qaqcCoverage.pending_missions?.length) {
366
+ errors.push(`QA/QC has pending cases for missions: ${qaqcCoverage.pending_missions.join(", ")}`);
367
+ }
368
+ }
369
+ return { ok: errors.length === 0, errors, warnings };
370
+ }
371
+
372
+ module.exports = {
373
+ buildBuildHarnessMarkdown,
374
+ buildTopology,
375
+ qaqcMissionCoverage,
376
+ normalizeEntityGraph,
377
+ verifyTopology,
378
+ };
@@ -11,6 +11,8 @@ const ARTIFACT_KINDS = new Set([
11
11
  "notification",
12
12
  "object",
13
13
  "query_job",
14
+ "feedback_ticket",
15
+ "ticket_batch",
14
16
  ]);
15
17
  const SENSITIVE_KEY = /(api[_-]?key|authorization|bearer|password|secret|token|credential)/i;
16
18
 
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ const DEFAULT_PACKAGE_NAME = "myte";
8
+ const DEFAULT_PACKAGE_LATEST_URL = "https://registry.npmjs.org/myte/latest";
9
+ const DEFAULT_TIMEOUT_MS = 1500;
10
+ const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
11
+ const DEFAULT_UNAVAILABLE_CACHE_TTL_MS = 15 * 60 * 1000;
12
+
13
+ function parseSemverish(version) {
14
+ const main = String(version || "")
15
+ .trim()
16
+ .replace(/^v/i, "")
17
+ .split(/[+-]/)[0];
18
+ const match = main.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
19
+ if (!match) return null;
20
+ return [match[1], match[2] || "0", match[3] || "0"].map((part) => Number(part));
21
+ }
22
+
23
+ function compareSemverish(left, right) {
24
+ const leftParts = parseSemverish(left);
25
+ const rightParts = parseSemverish(right);
26
+ if (!leftParts || !rightParts) return String(left || "").localeCompare(String(right || ""));
27
+ for (let index = 0; index < 3; index += 1) {
28
+ if (leftParts[index] > rightParts[index]) return 1;
29
+ if (leftParts[index] < rightParts[index]) return -1;
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ function isVersionNewer(latest, installed) {
35
+ return compareSemverish(latest, installed) > 0;
36
+ }
37
+
38
+ function defaultCacheDirectory(env = process.env) {
39
+ if (env.MYTE_PACKAGE_UPDATE_CACHE_DIR) return String(env.MYTE_PACKAGE_UPDATE_CACHE_DIR);
40
+ if (process.platform === "win32" && env.LOCALAPPDATA) {
41
+ return path.join(env.LOCALAPPDATA, "Myte", "cache");
42
+ }
43
+ if (process.platform === "darwin") {
44
+ return path.join(os.homedir(), "Library", "Caches", "Myte");
45
+ }
46
+ return path.join(env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "myte");
47
+ }
48
+
49
+ function cacheFilePath({ packageName = DEFAULT_PACKAGE_NAME, cacheDir } = {}) {
50
+ const safeName = String(packageName || DEFAULT_PACKAGE_NAME).replace(/[^a-z0-9._-]/gi, "_");
51
+ return path.join(cacheDir || defaultCacheDirectory(), `package-update-${safeName}.json`);
52
+ }
53
+
54
+ function readCache(filePath) {
55
+ try {
56
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
57
+ return value && typeof value === "object" ? value : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function writeCache(filePath, value) {
64
+ try {
65
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
+ const tempPath = `${filePath}.${process.pid}.tmp`;
67
+ fs.writeFileSync(tempPath, `${JSON.stringify(value)}\n`, "utf8");
68
+ fs.renameSync(tempPath, filePath);
69
+ } catch {
70
+ // The update notice is advisory; cache failures must never affect a CLI command.
71
+ }
72
+ }
73
+
74
+ function isFresh(record, now, ttlMs) {
75
+ const checkedAt = Number(record?.checked_at || 0);
76
+ const age = now - checkedAt;
77
+ return checkedAt > 0 && age >= 0 && age < ttlMs;
78
+ }
79
+
80
+ function packageStatusBase({
81
+ packageName = DEFAULT_PACKAGE_NAME,
82
+ installedVersion = null,
83
+ latestUrl = DEFAULT_PACKAGE_LATEST_URL,
84
+ updateCommand = `npm install -g ${packageName}@latest`,
85
+ } = {}) {
86
+ return {
87
+ name: packageName,
88
+ installed_version: installedVersion,
89
+ latest_version: null,
90
+ update_available: null,
91
+ check_status: "not-checked",
92
+ latest_url: latestUrl,
93
+ update_command: updateCommand,
94
+ };
95
+ }
96
+
97
+ async function defaultFetchJson(url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
98
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
99
+ const timeoutId = controller && timeoutMs > 0
100
+ ? setTimeout(() => controller.abort(), timeoutMs)
101
+ : undefined;
102
+ try {
103
+ const response = await fetch(url, {
104
+ method: "GET",
105
+ headers: { Accept: "application/json" },
106
+ signal: controller?.signal,
107
+ });
108
+ const text = await response.text();
109
+ let body = null;
110
+ try {
111
+ body = text.trim() ? JSON.parse(text) : null;
112
+ } catch {
113
+ body = null;
114
+ }
115
+ return { ok: Boolean(response.ok), status: response.status, body };
116
+ } finally {
117
+ if (timeoutId) clearTimeout(timeoutId);
118
+ }
119
+ }
120
+
121
+ function statusFromCache(base, record, { unavailable = false } = {}) {
122
+ return {
123
+ ...base,
124
+ latest_version: record.latest_version || null,
125
+ update_available: typeof record.update_available === "boolean" ? record.update_available : null,
126
+ check_status: unavailable ? "cached-unavailable" : "cached",
127
+ checked_at: record.checked_at || null,
128
+ };
129
+ }
130
+
131
+ async function checkPackageUpdate({
132
+ packageName = DEFAULT_PACKAGE_NAME,
133
+ installedVersion,
134
+ latestUrl = DEFAULT_PACKAGE_LATEST_URL,
135
+ updateCommand = `npm install -g ${packageName}@latest`,
136
+ enabled = true,
137
+ timeoutMs = DEFAULT_TIMEOUT_MS,
138
+ cacheTtlMs = DEFAULT_CACHE_TTL_MS,
139
+ unavailableCacheTtlMs = DEFAULT_UNAVAILABLE_CACHE_TTL_MS,
140
+ cacheDir,
141
+ fetchJson = defaultFetchJson,
142
+ now = () => Date.now(),
143
+ } = {}) {
144
+ const base = packageStatusBase({ packageName, installedVersion, latestUrl, updateCommand });
145
+ if (!enabled) return { ...base, check_status: "skipped" };
146
+
147
+ const currentTime = Number(now()) || Date.now();
148
+ const filePath = cacheDir === false ? null : cacheFilePath({ packageName, cacheDir });
149
+ const cached = filePath ? readCache(filePath) : null;
150
+ if (cached && cached.latest_url === latestUrl && cached.installed_version === installedVersion) {
151
+ if (cached.check_status === "ok" && isFresh(cached, currentTime, cacheTtlMs)) {
152
+ return statusFromCache(base, cached);
153
+ }
154
+ if (cached.check_status === "unavailable" && isFresh(cached, currentTime, unavailableCacheTtlMs)) {
155
+ return statusFromCache(base, cached, { unavailable: true });
156
+ }
157
+ }
158
+
159
+ try {
160
+ const response = await fetchJson(latestUrl, { timeoutMs });
161
+ if (!response?.ok) {
162
+ const unavailable = {
163
+ latest_url: latestUrl,
164
+ installed_version: installedVersion,
165
+ latest_version: null,
166
+ update_available: null,
167
+ check_status: "unavailable",
168
+ checked_at: currentTime,
169
+ };
170
+ if (filePath) writeCache(filePath, unavailable);
171
+ return { ...base, ...unavailable, registry_status: response?.status || null };
172
+ }
173
+
174
+ const latestVersion = String(response?.body?.version || "").trim();
175
+ if (!latestVersion || !parseSemverish(latestVersion)) {
176
+ return { ...base, check_status: "invalid-response", checked_at: currentTime };
177
+ }
178
+
179
+ const result = {
180
+ ...base,
181
+ latest_version: latestVersion,
182
+ update_available: isVersionNewer(latestVersion, installedVersion),
183
+ check_status: "ok",
184
+ checked_at: currentTime,
185
+ };
186
+ if (filePath) {
187
+ writeCache(filePath, {
188
+ latest_url: latestUrl,
189
+ installed_version: installedVersion,
190
+ latest_version: result.latest_version,
191
+ update_available: result.update_available,
192
+ check_status: result.check_status,
193
+ checked_at: result.checked_at,
194
+ });
195
+ }
196
+ return result;
197
+ } catch (error) {
198
+ const unavailable = {
199
+ latest_url: latestUrl,
200
+ installed_version: installedVersion,
201
+ latest_version: null,
202
+ update_available: null,
203
+ check_status: "unavailable",
204
+ checked_at: currentTime,
205
+ };
206
+ if (filePath) writeCache(filePath, unavailable);
207
+ return {
208
+ ...base,
209
+ ...unavailable,
210
+ error: error?.message || String(error),
211
+ };
212
+ }
213
+ }
214
+
215
+ function packageUpdateNotice(status) {
216
+ if (!status || status.update_available !== true) return "";
217
+ return `myte package update available: ${status.latest_version} (installed ${status.installed_version}). Run ${status.update_command}`;
218
+ }
219
+
220
+ module.exports = {
221
+ checkPackageUpdate,
222
+ compareSemverish,
223
+ defaultCacheDirectory,
224
+ isVersionNewer,
225
+ packageStatusBase,
226
+ packageUpdateNotice,
227
+ parseSemverish,
228
+ };