@openagentsinc/pylon 0.1.3 → 0.1.5

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/src/index.js CHANGED
@@ -5,6 +5,12 @@ import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import readline from "node:readline/promises";
8
+ import {
9
+ installSourceForTelemetry,
10
+ telemetryFailureContext,
11
+ } from "./telemetry.js";
12
+
13
+ export { createTelemetryClient } from "./telemetry.js";
8
14
 
9
15
  export const DEFAULT_RELEASE_REPO = "OpenAgentsInc/openagents";
10
16
  export const DEFAULT_RELEASE_API_BASE = "https://api.github.com";
@@ -18,6 +24,9 @@ const PYLON_RELEASE_TAG_PREFIX = "pylon-v";
18
24
  const RELEASE_ASSET_INSTALL_METHOD = "release_asset";
19
25
  const SOURCE_BUILD_INSTALL_METHOD = "source_build";
20
26
  const PREFERRED_RUNTIME_MODEL_NAME = "gemma4:e4b";
27
+ const LEGACY_SOURCE_BUILD_SIBLING_REPOSITORIES = {
28
+ "spark-sdk": "https://github.com/AtlantisPleb/spark-sdk.git",
29
+ };
21
30
 
22
31
  function emitStatus(onStatus, message, detail = null) {
23
32
  if (typeof onStatus === "function") {
@@ -31,10 +40,82 @@ function emitVerboseStatus(onStatus, verbose, message, detail = null) {
31
40
  }
32
41
  }
33
42
 
43
+ function emitTelemetry(telemetryClient, eventName, properties = {}) {
44
+ if (typeof telemetryClient?.emit === "function") {
45
+ void telemetryClient.emit(eventName, properties);
46
+ }
47
+ }
48
+
34
49
  function normalizeVersion(value) {
35
50
  return value.replace(/^pylon-v/, "").replace(/^v/, "");
36
51
  }
37
52
 
53
+ function parseComparableVersion(value) {
54
+ const normalized = normalizeVersion(value).trim();
55
+ const match = normalized.match(
56
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+?)(\d+)?)?$/,
57
+ );
58
+ if (!match) {
59
+ return null;
60
+ }
61
+
62
+ return {
63
+ normalized,
64
+ major: Number.parseInt(match[1], 10),
65
+ minor: Number.parseInt(match[2], 10),
66
+ patch: Number.parseInt(match[3], 10),
67
+ prereleaseLabel: match[4] ?? null,
68
+ prereleaseNumber:
69
+ match[5] != null ? Number.parseInt(match[5], 10) : null,
70
+ };
71
+ }
72
+
73
+ function comparePylonReleaseTags(leftTagName, rightTagName) {
74
+ const left = parseComparableVersion(leftTagName);
75
+ const right = parseComparableVersion(rightTagName);
76
+
77
+ if (!left && !right) {
78
+ return String(leftTagName).localeCompare(String(rightTagName));
79
+ }
80
+ if (!left) {
81
+ return -1;
82
+ }
83
+ if (!right) {
84
+ return 1;
85
+ }
86
+
87
+ for (const key of ["major", "minor", "patch"]) {
88
+ if (left[key] !== right[key]) {
89
+ return left[key] > right[key] ? 1 : -1;
90
+ }
91
+ }
92
+
93
+ if (left.prereleaseLabel == null && right.prereleaseLabel == null) {
94
+ return 0;
95
+ }
96
+ if (left.prereleaseLabel == null) {
97
+ return 1;
98
+ }
99
+ if (right.prereleaseLabel == null) {
100
+ return -1;
101
+ }
102
+
103
+ const labelComparison = left.prereleaseLabel.localeCompare(
104
+ right.prereleaseLabel,
105
+ );
106
+ if (labelComparison !== 0) {
107
+ return labelComparison;
108
+ }
109
+
110
+ const leftNumber = left.prereleaseNumber ?? 0;
111
+ const rightNumber = right.prereleaseNumber ?? 0;
112
+ if (leftNumber !== rightNumber) {
113
+ return leftNumber > rightNumber ? 1 : -1;
114
+ }
115
+
116
+ return left.normalized.localeCompare(right.normalized);
117
+ }
118
+
38
119
  function createBootstrapError(message, context = {}) {
39
120
  const error = new Error(message);
40
121
  Object.assign(error, context);
@@ -50,6 +131,27 @@ async function pathExists(value) {
50
131
  }
51
132
  }
52
133
 
134
+ async function findFilesNamed(rootDir, targetName, results = []) {
135
+ const entries = await fs.readdir(rootDir, { withFileTypes: true });
136
+ for (const entry of entries) {
137
+ if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target") {
138
+ continue;
139
+ }
140
+
141
+ const fullPath = path.join(rootDir, entry.name);
142
+ if (entry.isDirectory()) {
143
+ await findFilesNamed(fullPath, targetName, results);
144
+ continue;
145
+ }
146
+
147
+ if (entry.isFile() && entry.name === targetName) {
148
+ results.push(fullPath);
149
+ }
150
+ }
151
+
152
+ return results;
153
+ }
154
+
53
155
  function defaultInstallRoot() {
54
156
  return path.join(os.homedir(), ".openagents", "pylon", "bootstrap");
55
157
  }
@@ -497,14 +599,31 @@ export function isPylonReleaseTag(tagName) {
497
599
  );
498
600
  }
499
601
 
500
- export function selectLatestPylonRelease(releases) {
602
+ function releaseHasTargetAssets(release, target) {
603
+ if (!target || !release?.tag_name) {
604
+ return false;
605
+ }
606
+
607
+ const { archiveName, checksumName } = buildAssetNames(release.tag_name, target);
608
+ const assetNames = new Set(
609
+ (Array.isArray(release.assets) ? release.assets : [])
610
+ .map((asset) => asset?.name)
611
+ .filter(Boolean),
612
+ );
613
+ return assetNames.has(archiveName) && assetNames.has(checksumName);
614
+ }
615
+
616
+ export function selectLatestPylonRelease(releases, target = null) {
501
617
  if (!Array.isArray(releases)) {
502
618
  throw new Error("GitHub release lookup did not return a release list.");
503
619
  }
504
620
 
505
- const release = releases.find(
506
- (candidate) => !candidate?.draft && isPylonReleaseTag(candidate?.tag_name),
507
- );
621
+ const candidates = releases
622
+ .filter((candidate) => !candidate?.draft && isPylonReleaseTag(candidate?.tag_name))
623
+ .sort((left, right) => comparePylonReleaseTags(right.tag_name, left.tag_name));
624
+ const release =
625
+ candidates.find((candidate) => releaseHasTargetAssets(candidate, target)) ??
626
+ candidates[0];
508
627
  if (!release) {
509
628
  throw new Error(
510
629
  `GitHub release lookup did not find any published ${PYLON_RELEASE_TAG_PREFIX} releases.`,
@@ -522,6 +641,7 @@ export async function fetchReleaseMetadata({
522
641
  apiBase = DEFAULT_RELEASE_API_BASE,
523
642
  repo = DEFAULT_RELEASE_REPO,
524
643
  version = null,
644
+ target = null,
525
645
  } = {}) {
526
646
  const normalizedVersion = normalizeRequestedVersion(version);
527
647
  const endpoint = normalizedVersion
@@ -538,7 +658,7 @@ export async function fetchReleaseMetadata({
538
658
  ? "GitHub tagged release lookup"
539
659
  : "GitHub release list lookup",
540
660
  });
541
- return normalizedVersion ? payload : selectLatestPylonRelease(payload);
661
+ return normalizedVersion ? payload : selectLatestPylonRelease(payload, target);
542
662
  }
543
663
 
544
664
  export function selectReleaseAssets(release, target) {
@@ -704,6 +824,105 @@ function manualSourceBuildCommands(tagName, cloneUrl) {
704
824
  ].join("\n");
705
825
  }
706
826
 
827
+ function inferLegacySiblingRepositoryName(relativeDependencyPath) {
828
+ if (typeof relativeDependencyPath !== "string" || !relativeDependencyPath.startsWith("..")) {
829
+ return null;
830
+ }
831
+
832
+ const segments = relativeDependencyPath
833
+ .split(/[\\/]+/)
834
+ .filter(Boolean);
835
+ const firstRepoSegment = segments.find((segment) => segment !== "..");
836
+ if (!firstRepoSegment) {
837
+ return null;
838
+ }
839
+
840
+ return Object.hasOwn(LEGACY_SOURCE_BUILD_SIBLING_REPOSITORIES, firstRepoSegment)
841
+ ? firstRepoSegment
842
+ : null;
843
+ }
844
+
845
+ async function discoverLegacySiblingRepositories(repoDir) {
846
+ const cargoTomlFiles = await findFilesNamed(repoDir, "Cargo.toml");
847
+ const discovered = new Set();
848
+
849
+ for (const cargoTomlPath of cargoTomlFiles) {
850
+ const payload = await fs.readFile(cargoTomlPath, "utf8");
851
+ const matches = payload.matchAll(/path\s*=\s*"([^"]+)"/g);
852
+ for (const match of matches) {
853
+ const dependencyPath = match[1]?.trim();
854
+ const repoName = inferLegacySiblingRepositoryName(dependencyPath);
855
+ if (!repoName) {
856
+ continue;
857
+ }
858
+
859
+ const resolvedPath = path.resolve(path.dirname(cargoTomlPath), dependencyPath);
860
+ if (resolvedPath.startsWith(`${repoDir}${path.sep}`) || resolvedPath === repoDir) {
861
+ continue;
862
+ }
863
+
864
+ discovered.add(repoName);
865
+ }
866
+ }
867
+
868
+ return [...discovered];
869
+ }
870
+
871
+ async function hydrateLegacySiblingRepositories({
872
+ repoDir,
873
+ buildEnv,
874
+ runProcessImpl,
875
+ onStatus,
876
+ telemetryClient,
877
+ releaseTag,
878
+ target,
879
+ }) {
880
+ const repoNames = await discoverLegacySiblingRepositories(repoDir);
881
+ const workspaceRoot = path.dirname(repoDir);
882
+
883
+ for (const repoName of repoNames) {
884
+ const cloneUrl = LEGACY_SOURCE_BUILD_SIBLING_REPOSITORIES[repoName];
885
+ const checkoutDir = path.join(workspaceRoot, repoName);
886
+ if (!cloneUrl || (await pathExists(checkoutDir))) {
887
+ continue;
888
+ }
889
+
890
+ emitStatus(onStatus, "Hydrating legacy sibling checkout", repoName);
891
+ emitTelemetry(telemetryClient, "installer_legacy_sibling_checkout_started", {
892
+ release_tag: releaseTag,
893
+ os: target.os,
894
+ arch: target.arch,
895
+ repository: repoName,
896
+ });
897
+
898
+ try {
899
+ await runProcessImpl(
900
+ "git",
901
+ ["clone", "--depth", "1", cloneUrl, checkoutDir],
902
+ {
903
+ cwd: workspaceRoot,
904
+ env: buildEnv,
905
+ },
906
+ );
907
+ emitTelemetry(telemetryClient, "installer_legacy_sibling_checkout_completed", {
908
+ release_tag: releaseTag,
909
+ os: target.os,
910
+ arch: target.arch,
911
+ repository: repoName,
912
+ });
913
+ } catch (error) {
914
+ emitTelemetry(telemetryClient, "installer_legacy_sibling_checkout_failed", {
915
+ release_tag: releaseTag,
916
+ os: target.os,
917
+ arch: target.arch,
918
+ repository: repoName,
919
+ ...telemetryFailureContext(error, "legacy_sibling_checkout"),
920
+ });
921
+ throw error;
922
+ }
923
+ }
924
+ }
925
+
707
926
  function rustInstallCommand() {
708
927
  return "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y";
709
928
  }
@@ -713,6 +932,7 @@ async function ensureRustToolchain({
713
932
  fetchImpl,
714
933
  runProcessImpl,
715
934
  onStatus,
935
+ telemetryClient,
716
936
  promptImpl = promptForApproval,
717
937
  commandExistsImpl = commandExists,
718
938
  env = process.env,
@@ -725,44 +945,70 @@ async function ensureRustToolchain({
725
945
  return toolchainEnv;
726
946
  }
727
947
 
948
+ emitTelemetry(telemetryClient, "installer_rust_missing", {
949
+ os: target.os,
950
+ arch: target.arch,
951
+ });
952
+
728
953
  emitStatus(
729
954
  onStatus,
730
955
  "Rust toolchain required for source build",
731
956
  `${target.os}-${target.arch}`,
732
957
  );
733
958
 
959
+ emitTelemetry(telemetryClient, "installer_rust_install_prompt_shown", {
960
+ os: target.os,
961
+ arch: target.arch,
962
+ });
734
963
  const approved = await promptImpl(
735
964
  `Rust is required to build Pylon from source for ${target.os}-${target.arch}. Install the official Rust toolchain now via rustup?`,
736
965
  );
737
966
  if (!approved) {
967
+ emitTelemetry(telemetryClient, "installer_rust_install_declined", {
968
+ os: target.os,
969
+ arch: target.arch,
970
+ });
738
971
  throw new Error(
739
972
  `Rust is required to build Pylon from source.\nInstall it manually and rerun:\n${rustInstallCommand()}`,
740
973
  );
741
974
  }
742
975
 
743
- emitStatus(onStatus, "Installing Rust toolchain", "official rustup installer");
744
- const scriptPayload = await fetchText(fetchImpl, rustupInitUrl, {
745
- headers: {
746
- accept: "text/plain",
747
- "user-agent": "@openagentsinc/pylon bootstrap",
748
- },
749
- runProcessImpl,
750
- onStatus,
751
- stage: "Rust toolchain installer download",
976
+ emitTelemetry(telemetryClient, "installer_rust_install_approved", {
977
+ os: target.os,
978
+ arch: target.arch,
752
979
  });
753
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
754
- const scriptPath = path.join(tempDir, "rustup-init.sh");
755
980
 
981
+ emitStatus(onStatus, "Installing Rust toolchain", "official rustup installer");
756
982
  try {
983
+ const scriptPayload = await fetchText(fetchImpl, rustupInitUrl, {
984
+ headers: {
985
+ accept: "text/plain",
986
+ "user-agent": "@openagentsinc/pylon bootstrap",
987
+ },
988
+ runProcessImpl,
989
+ onStatus,
990
+ stage: "Rust toolchain installer download",
991
+ });
992
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
993
+ const scriptPath = path.join(tempDir, "rustup-init.sh");
757
994
  await fs.writeFile(scriptPath, scriptPayload);
758
995
  await fs.chmod(scriptPath, 0o755);
759
- await runProcessImpl("sh", [scriptPath, "-y"], {
760
- cwd: tempDir,
761
- env: toolchainEnv,
762
- stdio: "inherit",
996
+ try {
997
+ await runProcessImpl("sh", [scriptPath, "-y"], {
998
+ cwd: tempDir,
999
+ env: toolchainEnv,
1000
+ stdio: "inherit",
1001
+ });
1002
+ } finally {
1003
+ await fs.rm(tempDir, { recursive: true, force: true });
1004
+ }
1005
+ } catch (error) {
1006
+ emitTelemetry(telemetryClient, "installer_rust_install_failed", {
1007
+ os: target.os,
1008
+ arch: target.arch,
1009
+ ...telemetryFailureContext(error, "rust_install"),
763
1010
  });
764
- } finally {
765
- await fs.rm(tempDir, { recursive: true, force: true });
1011
+ throw error;
766
1012
  }
767
1013
 
768
1014
  toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
@@ -779,6 +1025,10 @@ async function ensureRustToolchain({
779
1025
  "Rust toolchain installed",
780
1026
  path.join(os.homedir(), ".cargo", "bin"),
781
1027
  );
1028
+ emitTelemetry(telemetryClient, "installer_rust_install_completed", {
1029
+ os: target.os,
1030
+ arch: target.arch,
1031
+ });
782
1032
  return toolchainEnv;
783
1033
  }
784
1034
 
@@ -793,6 +1043,7 @@ async function installSourceBuild(
793
1043
  fetchImpl,
794
1044
  runProcessImpl,
795
1045
  onStatus,
1046
+ telemetryClient,
796
1047
  promptImpl = promptForApproval,
797
1048
  commandExistsImpl = commandExists,
798
1049
  },
@@ -812,6 +1063,13 @@ async function installSourceBuild(
812
1063
  "Prebuilt asset missing; falling back to source build",
813
1064
  `${selected.tagName} for ${target.os}-${target.arch}`,
814
1065
  );
1066
+ emitTelemetry(telemetryClient, "installer_prebuilt_asset_missing", {
1067
+ release_tag: selected.tagName,
1068
+ release_commit: selected.targetCommitish ?? null,
1069
+ os: target.os,
1070
+ arch: target.arch,
1071
+ platform_key: `${target.os}-${target.arch}`,
1072
+ });
815
1073
 
816
1074
  if (!(await commandExistsImpl("git", process.env))) {
817
1075
  throw new Error(
@@ -824,6 +1082,7 @@ async function installSourceBuild(
824
1082
  fetchImpl,
825
1083
  runProcessImpl,
826
1084
  onStatus,
1085
+ telemetryClient,
827
1086
  promptImpl,
828
1087
  commandExistsImpl,
829
1088
  });
@@ -872,6 +1131,15 @@ async function installSourceBuild(
872
1131
  env: buildEnv,
873
1132
  },
874
1133
  );
1134
+ await hydrateLegacySiblingRepositories({
1135
+ repoDir,
1136
+ buildEnv,
1137
+ runProcessImpl,
1138
+ onStatus,
1139
+ telemetryClient,
1140
+ releaseTag: selected.tagName,
1141
+ target,
1142
+ });
875
1143
 
876
1144
  const { stdout: commitStdout } = await runProcessImpl(
877
1145
  "git",
@@ -897,6 +1165,13 @@ async function installSourceBuild(
897
1165
  "Building Pylon from source",
898
1166
  `${selected.tagName} (${sourceCommit.slice(0, 12)})`,
899
1167
  );
1168
+ emitTelemetry(telemetryClient, "installer_source_build_started", {
1169
+ release_tag: selected.tagName,
1170
+ release_commit: sourceCommit,
1171
+ os: target.os,
1172
+ arch: target.arch,
1173
+ platform_key: `${target.os}-${target.arch}`,
1174
+ });
900
1175
  await runProcessImpl(buildCommand[0], buildCommand.slice(1), {
901
1176
  cwd: repoDir,
902
1177
  env: buildEnv,
@@ -938,6 +1213,17 @@ async function installSourceBuild(
938
1213
  "Installed source-built binaries",
939
1214
  `${selected.tagName} for ${target.os}-${target.arch}`,
940
1215
  );
1216
+ emitTelemetry(telemetryClient, "installer_source_build_completed", {
1217
+ release_tag: selected.tagName,
1218
+ release_commit: sourceCommit,
1219
+ os: target.os,
1220
+ arch: target.arch,
1221
+ platform_key: `${target.os}-${target.arch}`,
1222
+ install_source: installSourceForTelemetry(
1223
+ SOURCE_BUILD_INSTALL_METHOD,
1224
+ false,
1225
+ ),
1226
+ });
941
1227
 
942
1228
  return {
943
1229
  ...selected,
@@ -950,6 +1236,14 @@ async function installSourceBuild(
950
1236
  sourceCommit,
951
1237
  };
952
1238
  } catch (error) {
1239
+ emitTelemetry(telemetryClient, "installer_source_build_failed", {
1240
+ release_tag: selected.tagName,
1241
+ release_commit: selected.targetCommitish ?? null,
1242
+ os: target.os,
1243
+ arch: target.arch,
1244
+ platform_key: `${target.os}-${target.arch}`,
1245
+ ...telemetryFailureContext(error, "source_build"),
1246
+ });
953
1247
  const message = error instanceof Error ? error.message : String(error);
954
1248
  throw new Error(
955
1249
  `${message}\nManual source-build fallback:\n${manualBuildInstructions}`,
@@ -1013,6 +1307,9 @@ async function findLatestCachedInstall(installRoot, target) {
1013
1307
  pylonTuiPath,
1014
1308
  expectedSha256: manifest.sha256 ?? null,
1015
1309
  cached: true,
1310
+ installMethod: manifest.installMethod ?? RELEASE_ASSET_INSTALL_METHOD,
1311
+ sourceCloneUrl: manifest.sourceCloneUrl ?? null,
1312
+ sourceCommit: manifest.sourceCommit ?? null,
1016
1313
  mtimeMs: manifestStat.mtimeMs,
1017
1314
  });
1018
1315
  } catch {
@@ -1124,6 +1421,7 @@ export async function ensureReleaseInstall(
1124
1421
  fetchImpl = globalThis.fetch,
1125
1422
  runProcessImpl = runProcess,
1126
1423
  onStatus = null,
1424
+ telemetryClient = null,
1127
1425
  promptImpl = promptForApproval,
1128
1426
  commandExistsImpl = commandExists,
1129
1427
  } = {},
@@ -1141,25 +1439,38 @@ export async function ensureReleaseInstall(
1141
1439
  const installRoot = options.installRoot ?? defaultInstallRoot();
1142
1440
  if (options.version) {
1143
1441
  const requestedPaths = buildInstallPaths(installRoot, options.version, target);
1442
+ const requestedManifest = await readInstallManifest(requestedPaths.manifestPath);
1144
1443
  const requestedCached =
1145
1444
  (await pathExists(requestedPaths.pylonPath)) &&
1146
1445
  (await pathExists(requestedPaths.pylonTuiPath));
1147
1446
  if (requestedCached) {
1447
+ const installMethod =
1448
+ requestedManifest?.installMethod ?? RELEASE_ASSET_INSTALL_METHOD;
1148
1449
  emitStatus(
1149
1450
  onStatus,
1150
- "Using cached standalone binaries",
1451
+ installMethod === SOURCE_BUILD_INSTALL_METHOD
1452
+ ? "Using cached source-built binaries"
1453
+ : "Using cached standalone binaries",
1151
1454
  `pylon-v${normalizeVersion(options.version)} for ${target.os}-${target.arch}`,
1152
1455
  );
1456
+ emitTelemetry(telemetryClient, "installer_cached_install_reused", {
1457
+ release_tag: `pylon-v${normalizeVersion(options.version)}`,
1458
+ release_commit: requestedManifest?.sourceCommit ?? null,
1459
+ os: target.os,
1460
+ arch: target.arch,
1461
+ platform_key: `${target.os}-${target.arch}`,
1462
+ install_source: installSourceForTelemetry(installMethod, true),
1463
+ });
1153
1464
  return {
1154
1465
  version: normalizeVersion(options.version),
1155
1466
  tagName: `pylon-v${normalizeVersion(options.version)}`,
1156
1467
  target,
1157
1468
  ...requestedPaths,
1158
- expectedSha256: await fs
1159
- .readFile(requestedPaths.manifestPath, "utf8")
1160
- .then((payload) => JSON.parse(payload).sha256)
1161
- .catch(() => null),
1469
+ expectedSha256: requestedManifest?.sha256 ?? null,
1162
1470
  cached: true,
1471
+ installMethod,
1472
+ sourceCloneUrl: requestedManifest?.sourceCloneUrl ?? null,
1473
+ sourceCommit: requestedManifest?.sourceCommit ?? null,
1163
1474
  };
1164
1475
  }
1165
1476
  }
@@ -1174,6 +1485,14 @@ export async function ensureReleaseInstall(
1174
1485
  apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
1175
1486
  repo: options.repo ?? DEFAULT_RELEASE_REPO,
1176
1487
  version: options.version ?? null,
1488
+ target,
1489
+ });
1490
+ emitTelemetry(telemetryClient, "installer_release_resolved", {
1491
+ release_tag: release?.tag_name ?? null,
1492
+ release_commit: release?.target_commitish ?? null,
1493
+ os: target.os,
1494
+ arch: target.arch,
1495
+ platform_key: `${target.os}-${target.arch}`,
1177
1496
  });
1178
1497
  } catch (error) {
1179
1498
  const cached = !options.version
@@ -1182,9 +1501,19 @@ export async function ensureReleaseInstall(
1182
1501
  if (cached) {
1183
1502
  emitStatus(
1184
1503
  onStatus,
1185
- "Using cached standalone binaries",
1504
+ cached.installMethod === SOURCE_BUILD_INSTALL_METHOD
1505
+ ? "Using cached source-built binaries"
1506
+ : "Using cached standalone binaries",
1186
1507
  `release lookup failed; falling back to ${cached.tagName}`,
1187
1508
  );
1509
+ emitTelemetry(telemetryClient, "installer_cached_install_reused", {
1510
+ release_tag: cached.tagName,
1511
+ release_commit: cached.sourceCommit ?? null,
1512
+ os: target.os,
1513
+ arch: target.arch,
1514
+ platform_key: `${target.os}-${target.arch}`,
1515
+ install_source: installSourceForTelemetry(cached.installMethod, true),
1516
+ });
1188
1517
  return cached;
1189
1518
  }
1190
1519
 
@@ -1205,6 +1534,14 @@ export async function ensureReleaseInstall(
1205
1534
  let missingAssetsError = null;
1206
1535
  try {
1207
1536
  selected = selectReleaseAssets(release, target);
1537
+ emitTelemetry(telemetryClient, "installer_prebuilt_asset_found", {
1538
+ release_tag: selected.tagName,
1539
+ release_commit: release?.target_commitish ?? null,
1540
+ asset_name: selected.archiveAsset.name,
1541
+ os: target.os,
1542
+ arch: target.arch,
1543
+ platform_key: `${target.os}-${target.arch}`,
1544
+ });
1208
1545
  } catch (error) {
1209
1546
  if (!(error instanceof MissingReleaseAssetsError)) {
1210
1547
  throw error;
@@ -1235,6 +1572,14 @@ export async function ensureReleaseInstall(
1235
1572
  : "Using cached standalone binaries",
1236
1573
  `${selected.tagName} for ${target.os}-${target.arch}`,
1237
1574
  );
1575
+ emitTelemetry(telemetryClient, "installer_cached_install_reused", {
1576
+ release_tag: selected.tagName,
1577
+ release_commit: manifest?.sourceCommit ?? release?.target_commitish ?? null,
1578
+ os: target.os,
1579
+ arch: target.arch,
1580
+ platform_key: `${target.os}-${target.arch}`,
1581
+ install_source: installSourceForTelemetry(installMethod, true),
1582
+ });
1238
1583
  return {
1239
1584
  ...selected,
1240
1585
  ...paths,
@@ -1259,6 +1604,7 @@ export async function ensureReleaseInstall(
1259
1604
  fetchImpl,
1260
1605
  runProcessImpl,
1261
1606
  onStatus,
1607
+ telemetryClient,
1262
1608
  promptImpl,
1263
1609
  commandExistsImpl,
1264
1610
  },
@@ -1293,20 +1639,62 @@ export async function ensureReleaseInstall(
1293
1639
  "Downloading standalone binaries",
1294
1640
  selected.archiveAsset.name,
1295
1641
  );
1296
- await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
1297
- runProcessImpl,
1298
- onStatus,
1299
- verbose: Boolean(options.verbose),
1300
- stage: "Release archive download",
1642
+ emitTelemetry(telemetryClient, "installer_prebuilt_download_started", {
1643
+ release_tag: selected.tagName,
1644
+ asset_name: selected.archiveAsset.name,
1645
+ os: target.os,
1646
+ arch: target.arch,
1647
+ platform_key: `${target.os}-${target.arch}`,
1301
1648
  });
1649
+ try {
1650
+ await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
1651
+ runProcessImpl,
1652
+ onStatus,
1653
+ verbose: Boolean(options.verbose),
1654
+ stage: "Release archive download",
1655
+ });
1656
+ emitTelemetry(telemetryClient, "installer_prebuilt_download_completed", {
1657
+ release_tag: selected.tagName,
1658
+ asset_name: selected.archiveAsset.name,
1659
+ os: target.os,
1660
+ arch: target.arch,
1661
+ platform_key: `${target.os}-${target.arch}`,
1662
+ });
1663
+ } catch (error) {
1664
+ emitTelemetry(telemetryClient, "installer_prebuilt_download_failed", {
1665
+ release_tag: selected.tagName,
1666
+ asset_name: selected.archiveAsset.name,
1667
+ os: target.os,
1668
+ arch: target.arch,
1669
+ platform_key: `${target.os}-${target.arch}`,
1670
+ ...telemetryFailureContext(error, "prebuilt_download"),
1671
+ });
1672
+ throw error;
1673
+ }
1302
1674
  }
1303
1675
 
1304
1676
  const actualSha256 = await sha256File(paths.archivePath);
1305
1677
  if (actualSha256 !== expectedSha256) {
1678
+ emitTelemetry(telemetryClient, "installer_checksum_failed", {
1679
+ release_tag: selected.tagName,
1680
+ asset_name: selected.archiveAsset.name,
1681
+ os: target.os,
1682
+ arch: target.arch,
1683
+ platform_key: `${target.os}-${target.arch}`,
1684
+ error_stage: "checksum_verify",
1685
+ error_code: "sha256_mismatch",
1686
+ });
1306
1687
  throw new Error(
1307
1688
  `SHA-256 verification failed for ${selected.archiveAsset.name}: expected ${expectedSha256}, got ${actualSha256}.`,
1308
1689
  );
1309
1690
  }
1691
+ emitTelemetry(telemetryClient, "installer_checksum_verified", {
1692
+ release_tag: selected.tagName,
1693
+ asset_name: selected.archiveAsset.name,
1694
+ os: target.os,
1695
+ arch: target.arch,
1696
+ platform_key: `${target.os}-${target.arch}`,
1697
+ });
1310
1698
 
1311
1699
  emitStatus(
1312
1700
  onStatus,
@@ -1348,6 +1736,9 @@ export async function ensureReleaseInstall(
1348
1736
  target,
1349
1737
  expectedSha256,
1350
1738
  cached: false,
1739
+ installMethod: RELEASE_ASSET_INSTALL_METHOD,
1740
+ sourceCloneUrl: null,
1741
+ sourceCommit: null,
1351
1742
  };
1352
1743
  }
1353
1744
 
@@ -1356,6 +1747,7 @@ export async function bootstrapInstalledPylon(
1356
1747
  {
1357
1748
  runProcessImpl = runProcess,
1358
1749
  onStatus = null,
1750
+ telemetryClient = null,
1359
1751
  } = {},
1360
1752
  ) {
1361
1753
  const pylonPath = path.resolve(options.pylonPath);
@@ -1365,125 +1757,176 @@ export async function bootstrapInstalledPylon(
1365
1757
  options.diagnosticRepeats ?? DEFAULT_DIAGNOSTIC_REPEATS;
1366
1758
  const diagnosticMaxOutputTokens =
1367
1759
  options.diagnosticMaxOutputTokens ?? DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS;
1760
+ emitTelemetry(telemetryClient, "installer_smoke_test_started", {
1761
+ release_tag: options.tagName ?? `pylon-v${options.version}`,
1762
+ release_commit: options.sourceCommit ?? null,
1763
+ os: options.target?.os ?? null,
1764
+ arch: options.target?.arch ?? null,
1765
+ platform_key:
1766
+ options.target?.os && options.target?.arch
1767
+ ? `${options.target.os}-${options.target.arch}`
1768
+ : null,
1769
+ install_source: installSourceForTelemetry(
1770
+ options.installMethod,
1771
+ Boolean(options.cached),
1772
+ ),
1773
+ });
1368
1774
 
1369
- emitStatus(onStatus, "Verifying Pylon binary", path.basename(pylonPath));
1370
- await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
1371
- emitStatus(onStatus, "Bootstrapping local Pylon identity");
1372
- const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
1373
- emitStatus(onStatus, "Checking runtime health");
1374
- const status = await runPylonJson(
1375
- pylonPath,
1376
- ["status", "--json"],
1377
- options,
1378
- runProcessImpl,
1379
- );
1380
- emitStatus(onStatus, "Scanning for local models");
1381
- const inventory = await runPylonJson(
1382
- pylonPath,
1383
- ["inventory", "--json"],
1384
- options,
1385
- runProcessImpl,
1386
- );
1387
-
1388
- let download = null;
1389
- if (!options.skipModelDownload) {
1390
- emitStatus(onStatus, "Downloading curated model bundle", model);
1391
- download = await runPylonJson(
1775
+ try {
1776
+ emitStatus(onStatus, "Verifying Pylon binary", path.basename(pylonPath));
1777
+ await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
1778
+ emitStatus(onStatus, "Bootstrapping local Pylon identity");
1779
+ const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
1780
+ emitStatus(onStatus, "Checking runtime health");
1781
+ const status = await runPylonJson(
1392
1782
  pylonPath,
1393
- ["gemma", "download", model, "--json"],
1783
+ ["status", "--json"],
1394
1784
  options,
1395
1785
  runProcessImpl,
1396
1786
  );
1397
- } else {
1398
- emitStatus(
1399
- onStatus,
1400
- "Skipping optional curated GGUF cache",
1401
- "use --download-curated-cache to prefetch Hugging Face weights",
1787
+ emitStatus(onStatus, "Scanning for local models");
1788
+ const inventory = await runPylonJson(
1789
+ pylonPath,
1790
+ ["inventory", "--json"],
1791
+ options,
1792
+ runProcessImpl,
1402
1793
  );
1403
- }
1404
1794
 
1405
- let diagnostic = null;
1406
- if (!options.skipDiagnostics) {
1407
- emitStatus(onStatus, "Running first-run diagnostic", model);
1408
- try {
1409
- diagnostic = await runPylonJson(
1795
+ let download = null;
1796
+ if (!options.skipModelDownload) {
1797
+ emitStatus(onStatus, "Downloading curated model bundle", model);
1798
+ download = await runPylonJson(
1410
1799
  pylonPath,
1411
- [
1412
- "gemma",
1413
- "diagnose",
1414
- model,
1415
- "--max-output-tokens",
1416
- String(diagnosticMaxOutputTokens),
1417
- "--repeats",
1418
- String(diagnosticRepeats),
1419
- "--json",
1420
- ],
1800
+ ["gemma", "download", model, "--json"],
1421
1801
  options,
1422
1802
  runProcessImpl,
1423
1803
  );
1424
- } catch (error) {
1425
- if (!isUnsupportedGemmaDiagnoseError(error)) {
1426
- throw error;
1427
- }
1804
+ } else {
1428
1805
  emitStatus(
1429
1806
  onStatus,
1430
- "Skipping first-run diagnostic",
1431
- "installed Pylon release does not expose gemma diagnose",
1807
+ "Skipping optional curated GGUF cache",
1808
+ "use --download-curated-cache to prefetch Hugging Face weights",
1432
1809
  );
1433
1810
  }
1434
- } else {
1435
- emitStatus(onStatus, "Skipping first-run diagnostic", model);
1436
- }
1437
1811
 
1438
- const diagnosticResult =
1439
- diagnostic?.results?.find((result) => result.model_id === model) ??
1440
- diagnostic?.results?.[0] ??
1441
- null;
1812
+ let diagnostic = null;
1813
+ if (!options.skipDiagnostics) {
1814
+ emitStatus(onStatus, "Running first-run diagnostic", model);
1815
+ try {
1816
+ diagnostic = await runPylonJson(
1817
+ pylonPath,
1818
+ [
1819
+ "gemma",
1820
+ "diagnose",
1821
+ model,
1822
+ "--max-output-tokens",
1823
+ String(diagnosticMaxOutputTokens),
1824
+ "--repeats",
1825
+ String(diagnosticRepeats),
1826
+ "--json",
1827
+ ],
1828
+ options,
1829
+ runProcessImpl,
1830
+ );
1831
+ } catch (error) {
1832
+ if (!isUnsupportedGemmaDiagnoseError(error)) {
1833
+ throw error;
1834
+ }
1835
+ emitStatus(
1836
+ onStatus,
1837
+ "Skipping first-run diagnostic",
1838
+ "installed Pylon release does not expose gemma diagnose",
1839
+ );
1840
+ }
1841
+ } else {
1842
+ emitStatus(onStatus, "Skipping first-run diagnostic", model);
1843
+ }
1442
1844
 
1443
- emitStatus(
1444
- onStatus,
1445
- "Bootstrap complete",
1446
- diagnosticResult?.status
1447
- ? `diagnostic ${diagnosticResult.status}`
1448
- : "smoke path complete",
1449
- );
1845
+ const diagnosticResult =
1846
+ diagnostic?.results?.find((result) => result.model_id === model) ??
1847
+ diagnostic?.results?.[0] ??
1848
+ null;
1450
1849
 
1451
- return {
1452
- version: options.version,
1453
- tagName: options.tagName ?? `pylon-v${options.version}`,
1454
- target: options.target,
1455
- cached: Boolean(options.cached),
1456
- binaries: {
1457
- pylon: pylonPath,
1458
- pylonTui: pylonTuiPath,
1459
- },
1460
- configPath: init?.config_path ?? options.configPath ?? null,
1461
- pylonHome: options.pylonHome ? path.resolve(options.pylonHome) : null,
1462
- init,
1463
- status,
1464
- inventory,
1465
- model,
1466
- download,
1467
- diagnostic,
1468
- diagnosticResult,
1469
- };
1850
+ emitStatus(
1851
+ onStatus,
1852
+ "Bootstrap complete",
1853
+ diagnosticResult?.status
1854
+ ? `diagnostic ${diagnosticResult.status}`
1855
+ : "smoke path complete",
1856
+ );
1857
+ emitTelemetry(telemetryClient, "installer_smoke_test_completed", {
1858
+ release_tag: options.tagName ?? `pylon-v${options.version}`,
1859
+ release_commit: options.sourceCommit ?? null,
1860
+ os: options.target?.os ?? null,
1861
+ arch: options.target?.arch ?? null,
1862
+ platform_key:
1863
+ options.target?.os && options.target?.arch
1864
+ ? `${options.target.os}-${options.target.arch}`
1865
+ : null,
1866
+ install_source: installSourceForTelemetry(
1867
+ options.installMethod,
1868
+ Boolean(options.cached),
1869
+ ),
1870
+ diagnostic_status: diagnosticResult?.status ?? null,
1871
+ });
1872
+
1873
+ return {
1874
+ version: options.version,
1875
+ tagName: options.tagName ?? `pylon-v${options.version}`,
1876
+ target: options.target,
1877
+ cached: Boolean(options.cached),
1878
+ installMethod: options.installMethod ?? RELEASE_ASSET_INSTALL_METHOD,
1879
+ binaries: {
1880
+ pylon: pylonPath,
1881
+ pylonTui: pylonTuiPath,
1882
+ },
1883
+ configPath: init?.config_path ?? options.configPath ?? null,
1884
+ pylonHome: options.pylonHome ? path.resolve(options.pylonHome) : null,
1885
+ init,
1886
+ status,
1887
+ inventory,
1888
+ model,
1889
+ download,
1890
+ diagnostic,
1891
+ diagnosticResult,
1892
+ };
1893
+ } catch (error) {
1894
+ emitTelemetry(telemetryClient, "installer_smoke_test_failed", {
1895
+ release_tag: options.tagName ?? `pylon-v${options.version}`,
1896
+ release_commit: options.sourceCommit ?? null,
1897
+ os: options.target?.os ?? null,
1898
+ arch: options.target?.arch ?? null,
1899
+ platform_key:
1900
+ options.target?.os && options.target?.arch
1901
+ ? `${options.target.os}-${options.target.arch}`
1902
+ : null,
1903
+ install_source: installSourceForTelemetry(
1904
+ options.installMethod,
1905
+ Boolean(options.cached),
1906
+ ),
1907
+ ...telemetryFailureContext(error, "smoke_test"),
1908
+ });
1909
+ throw error;
1910
+ }
1470
1911
  }
1471
1912
 
1472
- export async function launchInstalledPylonTui(
1913
+ export async function launchInstalledPylon(
1473
1914
  options,
1474
1915
  {
1475
1916
  runProcessImpl = runProcess,
1476
1917
  onStatus = null,
1477
1918
  } = {},
1478
1919
  ) {
1479
- const pylonTuiPath = path.resolve(options.pylonTuiPath);
1480
- emitStatus(onStatus, "Opening Pylon terminal UI", path.basename(pylonTuiPath));
1481
- return runProcessImpl(pylonTuiPath, [], {
1920
+ const pylonPath = path.resolve(options.pylonPath);
1921
+ emitStatus(onStatus, "Starting Pylon default earning loop", path.basename(pylonPath));
1922
+ return runProcessImpl(pylonPath, [], {
1482
1923
  env: buildPylonEnv(options),
1483
1924
  stdio: "inherit",
1484
1925
  });
1485
1926
  }
1486
1927
 
1928
+ export const launchInstalledPylonTui = launchInstalledPylon;
1929
+
1487
1930
  export function resolveBootstrapOutcome(summary) {
1488
1931
  const runtimeState =
1489
1932
  summary.status?.snapshot?.runtime?.authoritative_status ?? "unknown";
@@ -1546,7 +1989,7 @@ function renderBootstrapNextSteps(summary, outcome) {
1546
1989
  ];
1547
1990
 
1548
1991
  if (outcome.verdict === "fully online" || outcome.verdict === "runtime ready") {
1549
- lines.push("Next step: open the TUI with `pylon`, or keep using the package-managed launcher.");
1992
+ lines.push("Next step: run `pylon`; it starts the default online earning loop.");
1550
1993
  return lines;
1551
1994
  }
1552
1995
 
@@ -1572,6 +2015,7 @@ export function renderBootstrapSummary(summary) {
1572
2015
  `Verdict detail: ${outcome.detail}`,
1573
2016
  `Pylon release: ${summary.version} (${summary.target.os}-${summary.target.arch})`,
1574
2017
  `Archive source: ${summary.tagName}`,
2018
+ `Install source: ${installSourceForTelemetry(summary.installMethod, summary.cached).replaceAll("_", " ")}`,
1575
2019
  `Installed from cache: ${summary.cached ? "yes" : "no"}`,
1576
2020
  `Pylon binary: ${summary.binaries.pylon}`,
1577
2021
  `Pylon TUI: ${summary.binaries.pylonTui}`,