@f5-sales-demo/xcsh 22.4.5 → 22.4.7
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 +4 -0
- package/package.json +8 -8
- package/src/cli/plugin-cli.ts +26 -17
- 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/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
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 });
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,9 @@ export * from "./extensibility/extensions";
|
|
|
30
30
|
export * from "./extensibility/skills";
|
|
31
31
|
// Slash commands
|
|
32
32
|
export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./extensibility/slash-commands";
|
|
33
|
+
export { type HostInfo, host } from "./host/host";
|
|
34
|
+
export { type SoftwareRecipe, type SoftwareResolution, software } from "./host/software";
|
|
35
|
+
export type { IntegrationDefinition, IntegrationHandle, IntegrationSetupPlan } from "./integrations/types";
|
|
33
36
|
export type * from "./lsp";
|
|
34
37
|
// Main entry point
|
|
35
38
|
export * from "./main";
|
|
@@ -33,6 +33,8 @@ function freezeStrings(values: readonly string[]): readonly string[] {
|
|
|
33
33
|
function freezePlan(plan: IntegrationSetupPlan | undefined): IntegrationSetupPlan | undefined {
|
|
34
34
|
if (!plan) return undefined;
|
|
35
35
|
const frozen: IntegrationSetupPlan = {
|
|
36
|
+
...(plan.notes ? { notes: freezeStrings(plan.notes) } : {}),
|
|
37
|
+
...(plan.validate ? { validate: plan.validate } : {}),
|
|
36
38
|
pluginDependencies: freezeStrings(plan.pluginDependencies),
|
|
37
39
|
requiredEnvironment: freezeStrings(plan.requiredEnvironment),
|
|
38
40
|
profileFields: freezeStrings(plan.profileFields),
|
|
@@ -41,6 +43,14 @@ function freezePlan(plan: IntegrationSetupPlan | undefined): IntegrationSetupPla
|
|
|
41
43
|
Object.freeze({
|
|
42
44
|
...step,
|
|
43
45
|
argv: freezeStrings(step.argv),
|
|
46
|
+
...(step.archive
|
|
47
|
+
? {
|
|
48
|
+
archive: Object.freeze({
|
|
49
|
+
...step.archive,
|
|
50
|
+
versionArgs: freezeStrings(step.archive.versionArgs),
|
|
51
|
+
}),
|
|
52
|
+
}
|
|
53
|
+
: {}),
|
|
44
54
|
...(step.environment ? { environment: freezeStrings(step.environment) } : {}),
|
|
45
55
|
}),
|
|
46
56
|
),
|
|
@@ -82,8 +92,13 @@ function validatePlan(plan: IntegrationSetupPlan | undefined): void {
|
|
|
82
92
|
plan.steps.some(
|
|
83
93
|
step =>
|
|
84
94
|
!step ||
|
|
85
|
-
!(["install", "login"] as const).includes(step.kind) ||
|
|
86
|
-
|
|
95
|
+
!(["install", "login", "archive-install"] as const).includes(step.kind) ||
|
|
96
|
+
(step.kind === "archive-install"
|
|
97
|
+
? !step.archive ||
|
|
98
|
+
!/^\d+\.\d+\.\d+$/.test(step.archive.version) ||
|
|
99
|
+
!/^[a-f0-9]{64}$/.test(step.archive.sha256) ||
|
|
100
|
+
!validateArgv(step.archive.versionArgs)
|
|
101
|
+
: !validateArgv(step.argv)) ||
|
|
87
102
|
!Number.isInteger(step.timeoutMs) ||
|
|
88
103
|
step.timeoutMs < 1_000 ||
|
|
89
104
|
step.timeoutMs > MAX_SETUP_STEP_TIMEOUT_MS ||
|
|
@@ -134,7 +149,8 @@ export class IntegrationRegistry {
|
|
|
134
149
|
const existing = this.#entries.get(definition.id);
|
|
135
150
|
if (existing && existing.owner !== owner) throw new Error(`Integration ${definition.id} is already registered`);
|
|
136
151
|
|
|
137
|
-
|
|
152
|
+
let setupPlan = freezePlan(definition.setup);
|
|
153
|
+
let preparationRevision = 0;
|
|
138
154
|
const frozenDefinition = Object.freeze({
|
|
139
155
|
...definition,
|
|
140
156
|
dependencies: definition.dependencies ? freezeStrings(definition.dependencies) : undefined,
|
|
@@ -145,12 +161,28 @@ export class IntegrationRegistry {
|
|
|
145
161
|
id: definition.id,
|
|
146
162
|
name: definition.name,
|
|
147
163
|
plugin: definition.plugin,
|
|
148
|
-
setupPlan
|
|
164
|
+
get setupPlan() {
|
|
165
|
+
return setupPlan;
|
|
166
|
+
},
|
|
167
|
+
prepareSetup: async (signal?: AbortSignal) => {
|
|
168
|
+
const revision = ++preparationRevision;
|
|
169
|
+
if (!definition.prepareSetup) {
|
|
170
|
+
if (!setupPlan) throw new Error("No setup is declared");
|
|
171
|
+
return setupPlan;
|
|
172
|
+
}
|
|
173
|
+
const prepared = await definition.prepareSetup(signal);
|
|
174
|
+
signal?.throwIfAborted();
|
|
175
|
+
validatePlan(prepared);
|
|
176
|
+
if (revision !== preparationRevision || this.#entries.get(definition.id)?.handle !== handle)
|
|
177
|
+
throw new Error("Setup preparation changed; review again");
|
|
178
|
+
setupPlan = freezePlan(prepared)!;
|
|
179
|
+
return setupPlan;
|
|
180
|
+
},
|
|
149
181
|
get: (signal?: AbortSignal) => this.#get(definition.id, false, signal) as Promise<IntegrationSnapshot<T>>,
|
|
150
182
|
invalidate: () => this.invalidate(definition.id),
|
|
151
183
|
verifyAfterSetup: (reviewedPlan: IntegrationSetupPlan, signal?: AbortSignal) => {
|
|
152
184
|
const current = this.#entries.get(definition.id);
|
|
153
|
-
if (
|
|
185
|
+
if (current?.handle !== handle || reviewedPlan !== setupPlan)
|
|
154
186
|
throw new Error("Setup requires the current reviewed setup plan");
|
|
155
187
|
return this.#get(definition.id, true, signal) as Promise<IntegrationSnapshot<T>>;
|
|
156
188
|
},
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { installArchive } from "../host/archive";
|
|
2
|
+
import { host } from "../host/host";
|
|
1
3
|
import type { IntegrationHandle, IntegrationSetupPlan, IntegrationSetupStep, IntegrationSnapshot } from "./types";
|
|
2
4
|
|
|
3
5
|
export type PluginSetupTrigger = "direct-install" | "bulk-install" | "upgrade" | "cache-refresh" | "dependency-install";
|
|
@@ -9,6 +11,7 @@ export interface InstallAuthorizedSetupOptions {
|
|
|
9
11
|
readonly handles: readonly IntegrationHandle<unknown>[];
|
|
10
12
|
readonly run?: SetupStepRunner;
|
|
11
13
|
readonly signal?: AbortSignal;
|
|
14
|
+
readonly review?: (handle: IntegrationHandle<unknown>, plan: IntegrationSetupPlan) => Promise<void>;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export type SetupStepRunner = (step: IntegrationSetupStep, signal?: AbortSignal) => Promise<number>;
|
|
@@ -64,12 +67,15 @@ export function describeSetupPlan(handle: IntegrationHandle<unknown>): string {
|
|
|
64
67
|
`Required environment names: ${plan.requiredEnvironment.join(", ") || "none"}`,
|
|
65
68
|
`Person-profile categories: ${plan.profileFields.join(", ") || "none"}`,
|
|
66
69
|
];
|
|
70
|
+
lines.push(...(plan.notes ?? []));
|
|
67
71
|
if (plan.guidedAction?.kind === "context_wizard") {
|
|
68
72
|
lines.push("Guided action: native xcsh context wizard");
|
|
69
73
|
} else {
|
|
70
74
|
lines.push(
|
|
71
75
|
"Commands:",
|
|
72
|
-
...plan.steps.map(
|
|
76
|
+
...plan.steps.map(
|
|
77
|
+
step => ` ${step.kind}: ${JSON.stringify(step.archive ?? step.argv)} (timeout ${step.timeoutMs}ms)`,
|
|
78
|
+
),
|
|
73
79
|
"Verification:",
|
|
74
80
|
...plan.verification.map(step => ` ${JSON.stringify(step.argv)} (timeout ${step.timeoutMs}ms)`),
|
|
75
81
|
);
|
|
@@ -82,6 +88,11 @@ export function createSetupStepRunner(
|
|
|
82
88
|
options: SetupStepRunnerOptions = {},
|
|
83
89
|
): SetupStepRunner {
|
|
84
90
|
return async (step, signal) => {
|
|
91
|
+
if (step.kind === "archive-install") {
|
|
92
|
+
if (!step.archive) throw new Error("Missing archive installation specification");
|
|
93
|
+
await installArchive(step.archive, signal, step.timeoutMs);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
85
96
|
const environment = { ...process.env };
|
|
86
97
|
for (const name of step.environment ?? []) {
|
|
87
98
|
const value = resolveEnvironment(name);
|
|
@@ -93,7 +104,7 @@ export function createSetupStepRunner(
|
|
|
93
104
|
const interactiveInput = step.stdin === "inherit" || step.kind === "login";
|
|
94
105
|
const output = step.kind === "login" ? "inherit" : (options.nonInteractiveOutput ?? "inherit");
|
|
95
106
|
try {
|
|
96
|
-
const child = Bun.spawn([...step.argv], {
|
|
107
|
+
const child = Bun.spawn([host.findExecutable(step.argv[0]) ?? step.argv[0], ...step.argv.slice(1)], {
|
|
97
108
|
env: environment,
|
|
98
109
|
stdin: interactiveInput ? "inherit" : "ignore",
|
|
99
110
|
stdout: output,
|
|
@@ -117,6 +128,9 @@ export async function executeReviewedSetup<T>(
|
|
|
117
128
|
): Promise<IntegrationSnapshot<T>> {
|
|
118
129
|
if (reviewedPlan !== handle.setupPlan) throw new Error("Setup requires the current reviewed setup plan");
|
|
119
130
|
if (reviewedPlan.guidedAction) throw new Error("Guided setup must run through its native interactive action");
|
|
131
|
+
if (signal?.aborted) throw new Error("Integration setup cancelled");
|
|
132
|
+
await reviewedPlan.validate?.(signal);
|
|
133
|
+
if (reviewedPlan !== handle.setupPlan) throw new Error("Setup requires the current reviewed setup plan");
|
|
120
134
|
for (const step of reviewedPlan.steps) {
|
|
121
135
|
if (signal?.aborted) throw new Error("Integration setup cancelled");
|
|
122
136
|
if ((await run(step, signal)) !== 0) throw new Error(`Integration ${step.kind} step failed`);
|
|
@@ -154,5 +168,7 @@ export async function executeInstallAuthorizedSetup(
|
|
|
154
168
|
const handle = matches[0];
|
|
155
169
|
const current = await handle.get(options.signal);
|
|
156
170
|
if (current.state !== "setup_required" && current.state !== "degraded") return current;
|
|
157
|
-
|
|
171
|
+
const plan = handle.prepareSetup ? await handle.prepareSetup(options.signal) : handle.setupPlan!;
|
|
172
|
+
await options.review?.(handle, plan);
|
|
173
|
+
return executeReviewedSetup(handle, plan, options.run, options.signal);
|
|
158
174
|
}
|