@f5-sales-demo/xcsh 22.4.3 → 22.4.6
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/CHANGELOG.md +2 -0
- package/package.json +8 -8
- package/src/cli/linux-standalone-update.ts +118 -0
- package/src/cli/plugin-cli.ts +21 -14
- package/src/cli/update-cli.ts +11 -0
- package/src/cli.ts +4 -1
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/extensibility/plugins/marketplace/builtin-marketplace-snapshot.ts +1 -1
- package/src/extensibility/plugins/marketplace/builtin-marketplace.provenance.json +3 -3
- package/src/extensibility/plugins/marketplace/fetcher.ts +6 -0
- package/src/extensibility/plugins/marketplace/manager.ts +3 -0
- package/src/extensibility/plugins/marketplace/runtime.ts +10 -0
- package/src/extensibility/plugins/marketplace/types.ts +1 -0
- package/src/host/archive.ts +182 -0
- package/src/host/host.ts +78 -0
- package/src/host/management.ts +63 -0
- package/src/host/software.ts +370 -0
- package/src/index.ts +3 -0
- package/src/integrations/registry.ts +37 -5
- package/src/integrations/setup.ts +19 -3
- package/src/integrations/types.ts +17 -1
- package/src/internal-urls/api-catalog-qmd-index.generated.ts +2 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/btw-panel.ts +8 -6
- package/src/modes/components/plan-preview.ts +2 -6
- package/src/modes/components/question-transcript.ts +7 -19
- package/src/modes/components/searchable-select-list.ts +20 -6
- package/src/modes/components/selector-frame.ts +72 -4
- package/src/modes/components/transcript-notice.ts +8 -16
- package/src/modes/controllers/selector-controller.ts +10 -2
- package/src/person-profile/machine-collectors.ts +4 -86
- package/src/slash-commands/builtin-registry.ts +6 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { chmod, link as hardlink, lstat, mkdir, mkdtemp, rename, rm, symlink } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { createGunzip } from "node:zlib";
|
|
8
|
+
import { extract } from "tar-stream";
|
|
9
|
+
import type { ArchiveInstall } from "../integrations/types";
|
|
10
|
+
import { exposeExecutable } from "./host";
|
|
11
|
+
|
|
12
|
+
export function safeArchivePath(name: string): string {
|
|
13
|
+
if (
|
|
14
|
+
!name ||
|
|
15
|
+
name.includes("\\") ||
|
|
16
|
+
name.includes("\0") ||
|
|
17
|
+
name.startsWith("/") ||
|
|
18
|
+
/^[a-z]:/i.test(name) ||
|
|
19
|
+
name.split("/").includes("..")
|
|
20
|
+
)
|
|
21
|
+
throw new Error("Unsafe archive extraction path");
|
|
22
|
+
const normalized = posix.normalize(name);
|
|
23
|
+
if (normalized === "." || normalized.startsWith("../")) throw new Error("Unsafe archive extraction path");
|
|
24
|
+
return normalized.replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
async function unpack(file: string, stage: string, baseDir: string, signal: AbortSignal) {
|
|
27
|
+
const archive = extract();
|
|
28
|
+
const pendingLinks: { path: string; target: string; hard: boolean }[] = [];
|
|
29
|
+
const seen = new Set<string>();
|
|
30
|
+
let total = 0;
|
|
31
|
+
let entries = 0;
|
|
32
|
+
archive.on("entry", (header, stream, next) => {
|
|
33
|
+
stream.on("error", error => archive.destroy(error));
|
|
34
|
+
void (async () => {
|
|
35
|
+
signal.throwIfAborted();
|
|
36
|
+
const name = safeArchivePath(header.name);
|
|
37
|
+
if ((name !== baseDir && !name.startsWith(`${baseDir}/`)) || seen.has(name) || ++entries > 100_000)
|
|
38
|
+
throw new Error("Unsafe or duplicate archive extraction path");
|
|
39
|
+
seen.add(name);
|
|
40
|
+
total += header.size ?? 0;
|
|
41
|
+
if (total > 2 * 1024 ** 3) throw new Error("Archive extraction size limit exceeded");
|
|
42
|
+
const path = join(stage, name);
|
|
43
|
+
if (header.type === "directory") {
|
|
44
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
45
|
+
stream.resume();
|
|
46
|
+
} else if (header.type === "file" || header.type === "contiguous-file") {
|
|
47
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
48
|
+
await pipeline(stream, createWriteStream(path, { flags: "wx", mode: (header.mode ?? 0o600) & 0o777 }), {
|
|
49
|
+
signal,
|
|
50
|
+
});
|
|
51
|
+
} else if (header.type === "symlink" || header.type === "link") {
|
|
52
|
+
const target = header.linkname ?? "";
|
|
53
|
+
if (!target || isAbsolute(target) || target.includes("\\") || target.includes("\0"))
|
|
54
|
+
throw new Error("Unsafe archive link");
|
|
55
|
+
const resolved =
|
|
56
|
+
header.type === "link"
|
|
57
|
+
? safeArchivePath(target)
|
|
58
|
+
: posix.normalize(posix.join(posix.dirname(name), target));
|
|
59
|
+
if (!resolved.startsWith(`${baseDir}/`) && resolved !== baseDir)
|
|
60
|
+
throw new Error("Unsafe archive link target");
|
|
61
|
+
pendingLinks.push({
|
|
62
|
+
path,
|
|
63
|
+
target: header.type === "link" ? join(stage, resolved) : target,
|
|
64
|
+
hard: header.type === "link",
|
|
65
|
+
});
|
|
66
|
+
stream.resume();
|
|
67
|
+
} else throw new Error("Unsupported archive entry type");
|
|
68
|
+
})().then(
|
|
69
|
+
() => next(),
|
|
70
|
+
error => {
|
|
71
|
+
stream.destroy(error);
|
|
72
|
+
archive.destroy(error);
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
await pipeline(createReadStream(file), createGunzip(), archive, { signal });
|
|
77
|
+
// No deferred link may be an ancestor of any other entry, including another link.
|
|
78
|
+
const relativeLinks = pendingLinks.map(item => item.path.slice(stage.length + 1));
|
|
79
|
+
for (const linkPath of relativeLinks)
|
|
80
|
+
if ([...seen].some(name => name.startsWith(`${linkPath}/`))) throw new Error("Unsafe archive link ancestor");
|
|
81
|
+
// Create links only after extraction; no extracted file can traverse a symlink.
|
|
82
|
+
for (const item of pendingLinks) {
|
|
83
|
+
signal.throwIfAborted();
|
|
84
|
+
await mkdir(dirname(item.path), { recursive: true, mode: 0o700 });
|
|
85
|
+
if (item.hard) {
|
|
86
|
+
if (!(await lstat(item.target)).isFile()) throw new Error("Unsafe archive hard link");
|
|
87
|
+
await hardlink(item.target, item.path);
|
|
88
|
+
} else await symlink(item.target, item.path);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function installArchive(
|
|
92
|
+
archive: ArchiveInstall,
|
|
93
|
+
signal?: AbortSignal,
|
|
94
|
+
timeoutMs = 600_000,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
97
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
98
|
+
if (
|
|
99
|
+
!isAbsolute(archive.destination) ||
|
|
100
|
+
!isAbsolute(archive.link) ||
|
|
101
|
+
!/^[a-f0-9]{64}$/.test(archive.sha256) ||
|
|
102
|
+
new URL(archive.url).protocol !== "https:"
|
|
103
|
+
)
|
|
104
|
+
throw new Error("Invalid archive installation plan");
|
|
105
|
+
const baseDir = safeArchivePath(archive.baseDir);
|
|
106
|
+
const relativeExecutable = safeArchivePath(archive.executable);
|
|
107
|
+
if (baseDir.includes("/")) throw new Error("Invalid archive base directory");
|
|
108
|
+
combined.throwIfAborted();
|
|
109
|
+
await mkdir(dirname(archive.destination), { recursive: true, mode: 0o700 });
|
|
110
|
+
const stage = await mkdtemp(join(dirname(archive.destination), ".xcsh-stage-"));
|
|
111
|
+
let ownedDestination = false;
|
|
112
|
+
let ownedLink = false;
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(archive.url, {
|
|
115
|
+
signal: combined,
|
|
116
|
+
redirect: "error",
|
|
117
|
+
headers: { "User-Agent": "curl/8.5.0 xcsh-software" },
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok || !response.body) throw new Error("Archive download failed");
|
|
120
|
+
const file = join(stage, "download.tar.gz");
|
|
121
|
+
const hash = createHash("sha256");
|
|
122
|
+
let size = 0;
|
|
123
|
+
const reader = response.body.getReader();
|
|
124
|
+
const output = createWriteStream(file, { flags: "wx", mode: 0o600 });
|
|
125
|
+
try {
|
|
126
|
+
await pipeline(
|
|
127
|
+
Readable.from(
|
|
128
|
+
(async function* () {
|
|
129
|
+
while (true) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done) break;
|
|
132
|
+
size += value.length;
|
|
133
|
+
if (size > 1024 ** 3) throw new Error("Archive download size limit exceeded");
|
|
134
|
+
hash.update(value);
|
|
135
|
+
yield value;
|
|
136
|
+
}
|
|
137
|
+
})(),
|
|
138
|
+
),
|
|
139
|
+
output,
|
|
140
|
+
{ signal: combined },
|
|
141
|
+
);
|
|
142
|
+
} finally {
|
|
143
|
+
await reader.cancel().catch(() => {});
|
|
144
|
+
reader.releaseLock();
|
|
145
|
+
}
|
|
146
|
+
if (hash.digest("hex") !== archive.sha256) throw new Error("Archive SHA-256 checksum mismatch");
|
|
147
|
+
await unpack(file, stage, baseDir, combined);
|
|
148
|
+
const executable = join(stage, baseDir, relativeExecutable);
|
|
149
|
+
if (!(await lstat(executable)).isFile())
|
|
150
|
+
throw new Error("Archive executable is missing or is not a regular file");
|
|
151
|
+
await chmod(executable, 0o755);
|
|
152
|
+
const child = Bun.spawn([executable, ...archive.versionArgs], {
|
|
153
|
+
stdin: "ignore",
|
|
154
|
+
stdout: "pipe",
|
|
155
|
+
stderr: "ignore",
|
|
156
|
+
signal: AbortSignal.any([combined, AbortSignal.timeout(15_000)]),
|
|
157
|
+
killSignal: "SIGKILL",
|
|
158
|
+
});
|
|
159
|
+
const version = await new Response(child.stdout).text();
|
|
160
|
+
if ((await child.exited) !== 0 || !version.includes(archive.version))
|
|
161
|
+
throw new Error("Archive executable version verification failed");
|
|
162
|
+
combined.throwIfAborted();
|
|
163
|
+
// Exclusive reservation prevents a competing or unrelated destination from being replaced.
|
|
164
|
+
await mkdir(archive.destination, { mode: 0o700 });
|
|
165
|
+
ownedDestination = true;
|
|
166
|
+
await rename(join(stage, baseDir), join(archive.destination, baseDir));
|
|
167
|
+
await mkdir(dirname(archive.link), { recursive: true, mode: 0o700 });
|
|
168
|
+
combined.throwIfAborted();
|
|
169
|
+
const installed = join(archive.destination, baseDir, relativeExecutable);
|
|
170
|
+
await symlink(installed, archive.link);
|
|
171
|
+
ownedLink = true;
|
|
172
|
+
exposeExecutable(installed);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (ownedLink) await rm(archive.link, { force: true });
|
|
175
|
+
if (ownedDestination) await rm(archive.destination, { recursive: true, force: true });
|
|
176
|
+
if (combined.aborted)
|
|
177
|
+
throw new Error(signal?.aborted ? "Integration setup cancelled" : "Archive installation timed out");
|
|
178
|
+
throw error;
|
|
179
|
+
} finally {
|
|
180
|
+
await rm(stage, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/host/host.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { runCli } from "../person-profile/collectors";
|
|
6
|
+
import { type HostManagement, probeManagement } from "./management";
|
|
7
|
+
|
|
8
|
+
export interface HostInfo {
|
|
9
|
+
readonly os: NodeJS.Platform;
|
|
10
|
+
readonly architecture: string;
|
|
11
|
+
readonly management: HostManagement;
|
|
12
|
+
readonly packageManagers: Readonly<Partial<Record<"brew" | "apt" | "winget" | "sudo", string>>>;
|
|
13
|
+
readonly effectiveUid?: number;
|
|
14
|
+
readonly sudo: "available" | "unavailable" | "unknown";
|
|
15
|
+
readonly distribution?: string;
|
|
16
|
+
}
|
|
17
|
+
export function findExecutable(name: string): string | undefined {
|
|
18
|
+
const path =
|
|
19
|
+
Bun.which(name, { PATH: process.env.PATH }) ?? Bun.which(join(homedir(), ".local", "bin", name)) ?? undefined;
|
|
20
|
+
if (path) exposeExecutable(path);
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
export async function executableIdentity(path: string): Promise<string> {
|
|
24
|
+
const target = await realpath(path);
|
|
25
|
+
const info = await stat(target);
|
|
26
|
+
return JSON.stringify([
|
|
27
|
+
path,
|
|
28
|
+
target,
|
|
29
|
+
info.dev,
|
|
30
|
+
info.ino,
|
|
31
|
+
info.size,
|
|
32
|
+
info.mtimeMs,
|
|
33
|
+
info.mode,
|
|
34
|
+
createHash("sha256")
|
|
35
|
+
.update(await readFile(target))
|
|
36
|
+
.digest("hex"),
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
export function exposeExecutable(path: string): void {
|
|
40
|
+
const directory = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")));
|
|
41
|
+
const parts = (process.env.PATH ?? "").split(delimiter);
|
|
42
|
+
if (!parts.includes(directory)) process.env.PATH = [directory, ...parts].join(delimiter);
|
|
43
|
+
}
|
|
44
|
+
export async function getHost(signal?: AbortSignal): Promise<HostInfo> {
|
|
45
|
+
signal?.throwIfAborted();
|
|
46
|
+
const managers: Partial<Record<"brew" | "apt" | "winget" | "sudo", string>> = {};
|
|
47
|
+
for (const [key, name] of [
|
|
48
|
+
["brew", "brew"],
|
|
49
|
+
["apt", "apt-get"],
|
|
50
|
+
["winget", "winget"],
|
|
51
|
+
["sudo", "sudo"],
|
|
52
|
+
] as const) {
|
|
53
|
+
const path = Bun.which(name, { PATH: process.env.PATH });
|
|
54
|
+
if (path) managers[key] = path;
|
|
55
|
+
}
|
|
56
|
+
let distribution: string | undefined;
|
|
57
|
+
if (process.platform === "linux")
|
|
58
|
+
try {
|
|
59
|
+
distribution = (await readFile("/etc/os-release", "utf8")).match(/^ID=["']?([^"'\n]+)["']?/m)?.[1];
|
|
60
|
+
} catch {
|
|
61
|
+
/* unknown */
|
|
62
|
+
}
|
|
63
|
+
const effectiveUid = process.geteuid?.();
|
|
64
|
+
const sudoResult =
|
|
65
|
+
effectiveUid === 0 || !managers.sudo ? undefined : await runCli([managers.sudo, "-n", "-l"], 5000, signal);
|
|
66
|
+
const management = await probeManagement(signal);
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
os: process.platform,
|
|
70
|
+
architecture: process.arch,
|
|
71
|
+
management: management.status,
|
|
72
|
+
packageManagers: Object.freeze(managers),
|
|
73
|
+
effectiveUid,
|
|
74
|
+
sudo: effectiveUid === 0 || sudoResult?.exitCode === 0 ? "available" : managers.sudo ? "unknown" : "unavailable",
|
|
75
|
+
distribution,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export const host = Object.freeze({ get: getHost, findExecutable });
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { $which } from "@f5-sales-demo/pi-utils";
|
|
2
|
+
import { runCli } from "../person-profile/collectors";
|
|
3
|
+
import type { ManagementStatus } from "../person-profile/machine-profile";
|
|
4
|
+
|
|
5
|
+
export type HostManagement = "managed" | "unmanaged" | "unknown";
|
|
6
|
+
export interface ManagementProbe {
|
|
7
|
+
status: HostManagement;
|
|
8
|
+
details: ManagementStatus;
|
|
9
|
+
}
|
|
10
|
+
export function detectMdmVendor(output: string): string | undefined {
|
|
11
|
+
const names: [RegExp, string][] = [
|
|
12
|
+
[/jamf/i, "Jamf"],
|
|
13
|
+
[/intune|microsoft/i, "Intune"],
|
|
14
|
+
[/mosyle/i, "Mosyle"],
|
|
15
|
+
[/kandji/i, "Kandji"],
|
|
16
|
+
[/workspace one|airwatch/i, "Workspace ONE"],
|
|
17
|
+
[/addigy/i, "Addigy"],
|
|
18
|
+
[/simplemdm/i, "SimpleMDM"],
|
|
19
|
+
[/hexnode/i, "Hexnode"],
|
|
20
|
+
];
|
|
21
|
+
return names.find(([pattern]) => pattern.test(output))?.[1];
|
|
22
|
+
}
|
|
23
|
+
export function parseEnrollment(code: number, output: string): ManagementProbe {
|
|
24
|
+
if (code !== 0) return { status: "unknown", details: {} };
|
|
25
|
+
const match = output.match(/MDM enrollment:\s*(Yes|No)\b/i);
|
|
26
|
+
if (!match) return { status: "unknown", details: {} };
|
|
27
|
+
const managed = match[1].toLowerCase() === "yes";
|
|
28
|
+
const dep = output.match(/Enrolled via DEP:\s*(Yes|No)/i);
|
|
29
|
+
return {
|
|
30
|
+
status: managed ? "managed" : "unmanaged",
|
|
31
|
+
details: {
|
|
32
|
+
isManaged: managed,
|
|
33
|
+
...(managed ? { userApproved: output.includes("User Approved") } : {}),
|
|
34
|
+
...(dep ? { depEnrolled: dep[1].toLowerCase() === "yes" } : {}),
|
|
35
|
+
...(detectMdmVendor(output) ? { mdmVendor: detectMdmVendor(output) } : {}),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function probeManagement(signal?: AbortSignal, platform = process.platform): Promise<ManagementProbe> {
|
|
40
|
+
if (platform !== "darwin") {
|
|
41
|
+
// Absence of an agent is not evidence that a host is unmanaged.
|
|
42
|
+
if (platform === "linux")
|
|
43
|
+
for (const agent of ["puppet", "chef-client", "salt-minion", "ansible"]) {
|
|
44
|
+
if ($which(agent)) return { status: "managed", details: { isManaged: true, mdmVendor: agent } };
|
|
45
|
+
}
|
|
46
|
+
return { status: "unknown", details: {} };
|
|
47
|
+
}
|
|
48
|
+
const result = await runCli(["profiles", "status", "-type", "enrollment"], undefined, signal);
|
|
49
|
+
const probe = parseEnrollment(result.exitCode, result.stdout);
|
|
50
|
+
if (!probe.details.mdmVendor && ($which("jamf") || $which("/usr/local/bin/jamf"))) probe.details.mdmVendor = "Jamf";
|
|
51
|
+
if (probe.details.mdmVendor === "Jamf") {
|
|
52
|
+
const version = await runCli(["jamf", "version"], undefined, signal);
|
|
53
|
+
if (version.exitCode === 0) probe.details.mdmVersion = version.stdout.match(/version=([\d.]+)/)?.[1];
|
|
54
|
+
}
|
|
55
|
+
if (probe.status === "managed") {
|
|
56
|
+
const mdm = await runCli(["/usr/libexec/mdmclient", "DumpManagementStatus"], undefined, signal);
|
|
57
|
+
if (mdm.exitCode === 0) {
|
|
58
|
+
if (mdm.stdout.includes("DeviceIsSupervised = 1")) probe.details.isSupervised = true;
|
|
59
|
+
probe.details.organizationName = mdm.stdout.match(/OrganizationName\s*=\s*"?([^"\n;]+)"?/)?.[1]?.trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return probe;
|
|
63
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { IntegrationSetupPlan, IntegrationSetupStep } from "../integrations/types";
|
|
5
|
+
import { executableIdentity, exposeExecutable, findExecutable, getHost, type HostInfo } from "./host";
|
|
6
|
+
|
|
7
|
+
export interface ArchiveRecipe {
|
|
8
|
+
readonly manifestUrl: string;
|
|
9
|
+
readonly baseDir: string;
|
|
10
|
+
readonly executable: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SoftwareRecipe {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly executable: string;
|
|
15
|
+
readonly versionArgs: readonly string[];
|
|
16
|
+
readonly brew?: { readonly package: string; readonly cask?: boolean };
|
|
17
|
+
readonly apt?: readonly string[];
|
|
18
|
+
readonly winget?: string;
|
|
19
|
+
readonly archive?: ArchiveRecipe;
|
|
20
|
+
}
|
|
21
|
+
export interface SoftwareResolution {
|
|
22
|
+
readonly executable: string;
|
|
23
|
+
readonly installer: string;
|
|
24
|
+
readonly elevation: "none" | "sudo";
|
|
25
|
+
readonly steps: readonly IntegrationSetupStep[];
|
|
26
|
+
readonly notes: readonly string[];
|
|
27
|
+
readonly validate: (signal?: AbortSignal) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface SoftwareEnvironment {
|
|
30
|
+
host(signal?: AbortSignal): Promise<HostInfo>;
|
|
31
|
+
find(name: string): string | undefined;
|
|
32
|
+
identity(path: string): Promise<string>;
|
|
33
|
+
run(argv: readonly string[], signal?: AbortSignal): Promise<{ code: number; stdout: string; stderr: string }>;
|
|
34
|
+
exists(path: string): Promise<boolean>;
|
|
35
|
+
home: string;
|
|
36
|
+
dataHome: string;
|
|
37
|
+
}
|
|
38
|
+
async function boundedRun(argv: readonly string[], signal?: AbortSignal) {
|
|
39
|
+
const timeout = AbortSignal.timeout(15_000);
|
|
40
|
+
const child = Bun.spawn([...argv], {
|
|
41
|
+
stdin: "ignore",
|
|
42
|
+
stdout: "pipe",
|
|
43
|
+
stderr: "ignore",
|
|
44
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
45
|
+
killSignal: "SIGKILL",
|
|
46
|
+
});
|
|
47
|
+
const reader = child.stdout.getReader();
|
|
48
|
+
const chunks: Uint8Array[] = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
try {
|
|
51
|
+
while (true) {
|
|
52
|
+
const { value, done } = await reader.read();
|
|
53
|
+
if (done) break;
|
|
54
|
+
size += value.length;
|
|
55
|
+
if (size > 1024 * 1024) {
|
|
56
|
+
child.kill("SIGKILL");
|
|
57
|
+
throw new Error("Software probe exceeded output limit");
|
|
58
|
+
}
|
|
59
|
+
chunks.push(value);
|
|
60
|
+
}
|
|
61
|
+
return { code: await child.exited, stdout: Buffer.concat(chunks).toString(), stderr: "" };
|
|
62
|
+
} finally {
|
|
63
|
+
reader.releaseLock();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const environment: SoftwareEnvironment = {
|
|
67
|
+
host: getHost,
|
|
68
|
+
find: findExecutable,
|
|
69
|
+
identity: executableIdentity,
|
|
70
|
+
run: boundedRun,
|
|
71
|
+
home: homedir(),
|
|
72
|
+
dataHome: process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
|
|
73
|
+
exists: async path => {
|
|
74
|
+
try {
|
|
75
|
+
await lstat(path);
|
|
76
|
+
return true;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
export function parseArchiveManifest(value: unknown, architecture: string, recipe: ArchiveRecipe) {
|
|
84
|
+
const data = value as { version?: unknown; gz?: unknown; sha256gz?: unknown; baseDir?: unknown };
|
|
85
|
+
if (
|
|
86
|
+
!data ||
|
|
87
|
+
typeof data.version !== "string" ||
|
|
88
|
+
!/^\d+\.\d+\.\d+$/.test(data.version) ||
|
|
89
|
+
typeof data.gz !== "string" ||
|
|
90
|
+
typeof data.sha256gz !== "string" ||
|
|
91
|
+
!/^[a-f0-9]{64}$/.test(data.sha256gz) ||
|
|
92
|
+
data.baseDir !== recipe.baseDir
|
|
93
|
+
)
|
|
94
|
+
throw new Error("Invalid official archive manifest");
|
|
95
|
+
const url = new URL(data.gz);
|
|
96
|
+
const authority = new URL(recipe.manifestUrl);
|
|
97
|
+
if (
|
|
98
|
+
url.protocol !== "https:" ||
|
|
99
|
+
url.origin !== authority.origin ||
|
|
100
|
+
url.username ||
|
|
101
|
+
url.password ||
|
|
102
|
+
url.search ||
|
|
103
|
+
url.hash ||
|
|
104
|
+
!url.pathname.includes(`/versions/${data.version}/`) ||
|
|
105
|
+
!url.pathname.endsWith(`-linux-${architecture}.tar.gz`)
|
|
106
|
+
)
|
|
107
|
+
throw new Error("Invalid official archive manifest URL");
|
|
108
|
+
return { version: data.version, url: url.href, sha256: data.sha256gz };
|
|
109
|
+
}
|
|
110
|
+
export async function resolveSoftware(
|
|
111
|
+
recipe: SoftwareRecipe,
|
|
112
|
+
signal?: AbortSignal,
|
|
113
|
+
env = environment,
|
|
114
|
+
): Promise<SoftwareResolution> {
|
|
115
|
+
signal?.throwIfAborted();
|
|
116
|
+
if (
|
|
117
|
+
!/^[a-z][a-z0-9_-]*$/.test(recipe.id) ||
|
|
118
|
+
!/^[a-z][a-z0-9_-]*$/.test(recipe.executable) ||
|
|
119
|
+
!recipe.versionArgs.length ||
|
|
120
|
+
recipe.versionArgs.some(arg => typeof arg !== "string" || arg.includes("\0")) ||
|
|
121
|
+
recipe.apt?.some(pkg => !/^[a-z0-9][a-z0-9+.-]*$/.test(pkg)) ||
|
|
122
|
+
(recipe.brew && !/^[a-z0-9][a-z0-9@+._/-]*$/.test(recipe.brew.package)) ||
|
|
123
|
+
(recipe.winget && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(recipe.winget))
|
|
124
|
+
)
|
|
125
|
+
throw new Error("Invalid software recipe");
|
|
126
|
+
const info = await env.host(signal);
|
|
127
|
+
const existing = env.find(recipe.executable);
|
|
128
|
+
const managersFor = (current: HostInfo) =>
|
|
129
|
+
existing
|
|
130
|
+
? []
|
|
131
|
+
: current.os === "darwin"
|
|
132
|
+
? [current.packageManagers.brew]
|
|
133
|
+
: current.os === "win32"
|
|
134
|
+
? [current.packageManagers.winget]
|
|
135
|
+
: recipe.archive
|
|
136
|
+
? []
|
|
137
|
+
: [
|
|
138
|
+
current.packageManagers.apt,
|
|
139
|
+
...(current.effectiveUid === 0 ? [] : [current.packageManagers.sudo]),
|
|
140
|
+
];
|
|
141
|
+
const fingerprint = async (current: HostInfo) =>
|
|
142
|
+
JSON.stringify([
|
|
143
|
+
current,
|
|
144
|
+
env.find(recipe.executable),
|
|
145
|
+
...(await Promise.all(
|
|
146
|
+
managersFor(current)
|
|
147
|
+
.filter((path): path is string => path !== undefined)
|
|
148
|
+
.map(path => env.identity(path)),
|
|
149
|
+
)),
|
|
150
|
+
env.find(recipe.executable) ? await env.identity(env.find(recipe.executable)!) : undefined,
|
|
151
|
+
]);
|
|
152
|
+
const initial = await fingerprint(info);
|
|
153
|
+
let executable = existing ?? recipe.executable;
|
|
154
|
+
let installer = "existing executable";
|
|
155
|
+
let elevation: "none" | "sudo" = "none";
|
|
156
|
+
const steps: IntegrationSetupStep[] = [];
|
|
157
|
+
const notes: string[] = [];
|
|
158
|
+
const aptPolicies = new Map<string, string>();
|
|
159
|
+
let archiveDestination: string | undefined;
|
|
160
|
+
let link: string | undefined;
|
|
161
|
+
if (!existing && (await env.exists(join(env.home, ".local", "bin", recipe.executable))))
|
|
162
|
+
throw new Error("Conflicting executable path exists. Inspect ~/.local/bin before reviewing setup again.");
|
|
163
|
+
if (existing) {
|
|
164
|
+
let valid = false;
|
|
165
|
+
try {
|
|
166
|
+
const result = await env.run([existing, ...recipe.versionArgs], signal);
|
|
167
|
+
valid = result.code === 0 && /\d+\.\d+/.test(result.stdout);
|
|
168
|
+
} catch {
|
|
169
|
+
signal?.throwIfAborted();
|
|
170
|
+
}
|
|
171
|
+
if (!valid)
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${recipe.executable} at ${existing} did not return a working version. Repair or remove that installation, then review setup again.`,
|
|
174
|
+
);
|
|
175
|
+
notes.push(`Reuse existing executable: ${existing}`);
|
|
176
|
+
if (env === environment) exposeExecutable(existing);
|
|
177
|
+
} else if (info.os === "darwin") {
|
|
178
|
+
if (!recipe.brew || !info.packageManagers.brew)
|
|
179
|
+
throw new Error("Homebrew is required on macOS. Install or make Homebrew available, then review setup again.");
|
|
180
|
+
installer = "Homebrew";
|
|
181
|
+
steps.push({
|
|
182
|
+
kind: "install",
|
|
183
|
+
argv: [info.packageManagers.brew, "install", ...(recipe.brew.cask ? ["--cask"] : []), recipe.brew.package],
|
|
184
|
+
timeoutMs: 600_000,
|
|
185
|
+
});
|
|
186
|
+
} else if (info.os === "win32") {
|
|
187
|
+
if (!recipe.winget || !info.packageManagers.winget)
|
|
188
|
+
throw new Error("The official winget package and winget are required on Windows.");
|
|
189
|
+
const packageInfo = await env.run(
|
|
190
|
+
[
|
|
191
|
+
info.packageManagers.winget,
|
|
192
|
+
"show",
|
|
193
|
+
"--exact",
|
|
194
|
+
"--id",
|
|
195
|
+
recipe.winget,
|
|
196
|
+
"--source",
|
|
197
|
+
"winget",
|
|
198
|
+
"--accept-source-agreements",
|
|
199
|
+
"--disable-interactivity",
|
|
200
|
+
],
|
|
201
|
+
signal,
|
|
202
|
+
);
|
|
203
|
+
if (packageInfo.code !== 0)
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Official winget package ${recipe.winget} is unavailable. Install the current official CLI yourself and review setup again.`,
|
|
206
|
+
);
|
|
207
|
+
installer = "winget";
|
|
208
|
+
steps.push({
|
|
209
|
+
kind: "install",
|
|
210
|
+
argv: [info.packageManagers.winget, "install", "--exact", "--id", recipe.winget],
|
|
211
|
+
timeoutMs: 600_000,
|
|
212
|
+
});
|
|
213
|
+
} else if (info.os === "linux" && recipe.archive) {
|
|
214
|
+
if (!["x64", "arm64"].includes(info.architecture))
|
|
215
|
+
throw new Error(`Unsupported archive architecture: ${info.architecture}`);
|
|
216
|
+
const url = recipe.archive.manifestUrl.replace("{arch}", info.architecture);
|
|
217
|
+
const timeout = AbortSignal.timeout(30_000);
|
|
218
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
219
|
+
const response = await fetch(url, {
|
|
220
|
+
signal: combined,
|
|
221
|
+
redirect: "error",
|
|
222
|
+
headers: { "User-Agent": "curl/8.5.0 xcsh-software" },
|
|
223
|
+
});
|
|
224
|
+
if (!response.ok) throw new Error("Unable to fetch official stable archive manifest");
|
|
225
|
+
const raw = await response.text();
|
|
226
|
+
if (raw.length > 65536) throw new Error("Invalid official archive manifest size");
|
|
227
|
+
const archive = parseArchiveManifest(JSON.parse(raw), info.architecture, recipe.archive);
|
|
228
|
+
archiveDestination = join(env.dataHome, "xcsh", "software", recipe.id, archive.version);
|
|
229
|
+
link = join(env.home, ".local", "bin", recipe.executable);
|
|
230
|
+
if ((await env.exists(archiveDestination)) || (await env.exists(link)))
|
|
231
|
+
throw new Error(
|
|
232
|
+
"Archive destination or executable link already exists. Inspect that path before reviewing setup again.",
|
|
233
|
+
);
|
|
234
|
+
executable = join(archiveDestination, recipe.archive.baseDir, recipe.archive.executable);
|
|
235
|
+
installer = "official archive";
|
|
236
|
+
steps.push({
|
|
237
|
+
kind: "archive-install",
|
|
238
|
+
argv: [],
|
|
239
|
+
timeoutMs: 600_000,
|
|
240
|
+
archive: {
|
|
241
|
+
...archive,
|
|
242
|
+
destination: archiveDestination,
|
|
243
|
+
link,
|
|
244
|
+
baseDir: recipe.archive.baseDir,
|
|
245
|
+
executable: recipe.archive.executable,
|
|
246
|
+
versionArgs: recipe.versionArgs,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
notes.push(
|
|
250
|
+
`Archive ${archive.version}: ${archive.url}`,
|
|
251
|
+
`SHA-256: ${archive.sha256}`,
|
|
252
|
+
`Destination: ${archiveDestination}`,
|
|
253
|
+
`For direct shell use, add ${join(env.home, ".local", "bin")} to PATH. xcsh makes the executable available to subsequent setup and tools.`,
|
|
254
|
+
);
|
|
255
|
+
} else if (info.os === "linux") {
|
|
256
|
+
if (!["debian", "ubuntu"].includes(info.distribution ?? "") || !recipe.apt?.length || !info.packageManagers.apt)
|
|
257
|
+
throw new Error(
|
|
258
|
+
"Declared apt packages on Debian/Ubuntu are required; configure the prerequisite yourself before setup.",
|
|
259
|
+
);
|
|
260
|
+
const aptCache = join(info.packageManagers.apt.slice(0, info.packageManagers.apt.lastIndexOf("/")), "apt-cache");
|
|
261
|
+
for (const pkg of recipe.apt) {
|
|
262
|
+
const result = await env.run([aptCache, "policy", pkg], signal);
|
|
263
|
+
aptPolicies.set(pkg, result.stdout);
|
|
264
|
+
if (result.code !== 0 || !/Candidate:\s*(?!\(none\))\S+/.test(result.stdout))
|
|
265
|
+
throw new Error(
|
|
266
|
+
`apt package ${pkg} is unavailable in configured repositories. Configure the prerequisite yourself, then review setup.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (info.effectiveUid !== 0) {
|
|
270
|
+
if (!info.packageManagers.sudo || info.sudo !== "available")
|
|
271
|
+
throw new Error("apt installation requires root or sudo capability");
|
|
272
|
+
elevation = "sudo";
|
|
273
|
+
}
|
|
274
|
+
installer = "apt";
|
|
275
|
+
steps.push({
|
|
276
|
+
kind: "install",
|
|
277
|
+
argv: [
|
|
278
|
+
...(elevation === "sudo" ? [info.packageManagers.sudo!] : []),
|
|
279
|
+
info.packageManagers.apt,
|
|
280
|
+
"install",
|
|
281
|
+
"--yes",
|
|
282
|
+
...recipe.apt,
|
|
283
|
+
],
|
|
284
|
+
timeoutMs: 600_000,
|
|
285
|
+
...(elevation === "sudo" ? { stdin: "inherit" } : {}),
|
|
286
|
+
});
|
|
287
|
+
} else throw new Error(`Unsupported host: ${info.os}`);
|
|
288
|
+
notes.unshift(`Installer: ${installer}; required elevation: ${elevation}`);
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
executable,
|
|
291
|
+
installer,
|
|
292
|
+
elevation,
|
|
293
|
+
steps: Object.freeze(
|
|
294
|
+
steps.map(step =>
|
|
295
|
+
Object.freeze({
|
|
296
|
+
...step,
|
|
297
|
+
argv: Object.freeze([...step.argv]),
|
|
298
|
+
...(step.archive
|
|
299
|
+
? {
|
|
300
|
+
archive: Object.freeze({
|
|
301
|
+
...step.archive,
|
|
302
|
+
versionArgs: Object.freeze([...step.archive.versionArgs]),
|
|
303
|
+
}),
|
|
304
|
+
}
|
|
305
|
+
: {}),
|
|
306
|
+
}),
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
notes: Object.freeze(notes),
|
|
310
|
+
validate: async (currentSignal?: AbortSignal) => {
|
|
311
|
+
currentSignal?.throwIfAborted();
|
|
312
|
+
if (
|
|
313
|
+
initial !== (await fingerprint(await env.host(currentSignal))) ||
|
|
314
|
+
(archiveDestination && (await env.exists(archiveDestination))) ||
|
|
315
|
+
(link && (await env.exists(link)))
|
|
316
|
+
)
|
|
317
|
+
throw new Error("Software identity or prerequisites changed; review setup again");
|
|
318
|
+
if (installer === "winget") {
|
|
319
|
+
const packageInfo = await env.run(
|
|
320
|
+
[
|
|
321
|
+
info.packageManagers.winget!,
|
|
322
|
+
"show",
|
|
323
|
+
"--exact",
|
|
324
|
+
"--id",
|
|
325
|
+
recipe.winget!,
|
|
326
|
+
"--source",
|
|
327
|
+
"winget",
|
|
328
|
+
"--accept-source-agreements",
|
|
329
|
+
"--disable-interactivity",
|
|
330
|
+
],
|
|
331
|
+
currentSignal,
|
|
332
|
+
);
|
|
333
|
+
if (packageInfo.code !== 0) throw new Error("winget prerequisites changed; review setup again");
|
|
334
|
+
}
|
|
335
|
+
if (existing) {
|
|
336
|
+
const result = await env.run([existing, ...recipe.versionArgs], currentSignal);
|
|
337
|
+
if (result.code !== 0 || !/\d+\.\d+/.test(result.stdout))
|
|
338
|
+
throw new Error("Existing executable changed; review setup again");
|
|
339
|
+
}
|
|
340
|
+
if (installer === "apt") {
|
|
341
|
+
for (const pkg of recipe.apt!) {
|
|
342
|
+
const cache = join(
|
|
343
|
+
info.packageManagers.apt!.slice(0, info.packageManagers.apt!.lastIndexOf("/")),
|
|
344
|
+
"apt-cache",
|
|
345
|
+
);
|
|
346
|
+
const result = await env.run([cache, "policy", pkg], currentSignal);
|
|
347
|
+
if (result.code !== 0 || result.stdout !== aptPolicies.get(pkg))
|
|
348
|
+
throw new Error("apt prerequisites changed; review setup again");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
export async function prepareSoftwareSetup(
|
|
355
|
+
recipe: SoftwareRecipe,
|
|
356
|
+
plan: IntegrationSetupPlan,
|
|
357
|
+
signal?: AbortSignal,
|
|
358
|
+
): Promise<IntegrationSetupPlan> {
|
|
359
|
+
const resolution = await resolveSoftware(recipe, signal);
|
|
360
|
+
const map = (argv: readonly string[]) =>
|
|
361
|
+
argv[0] === recipe.executable ? [resolution.executable, ...argv.slice(1)] : argv;
|
|
362
|
+
return {
|
|
363
|
+
...plan,
|
|
364
|
+
steps: [...resolution.steps, ...plan.steps.map(step => ({ ...step, argv: map(step.argv) }))],
|
|
365
|
+
verification: plan.verification.map(step => ({ ...step, argv: map(step.argv) })),
|
|
366
|
+
notes: resolution.notes,
|
|
367
|
+
validate: resolution.validate,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
export const software = Object.freeze({ resolve: resolveSoftware, prepareSetup: prepareSoftwareSetup });
|