@qilin-agent/cli 0.0.0-stage → 3.1.3
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/LICENSE +21 -0
- package/README.i18n.yaml +22 -0
- package/README.md +69 -2
- package/README.zh.md +70 -0
- package/lib/bin.js +255 -0
- package/lib/dump-config-_HRfhhWz.js +53 -0
- package/lib/plugin-BQ1sn-63.js +239 -0
- package/lib/profile-boot-DSy6PUHQ.js +315 -0
- package/lib/profile-boot.js +2 -0
- package/lib/types/args.d.ts +56 -0
- package/lib/types/bin.d.ts +11 -0
- package/lib/types/dump-config.d.ts +18 -0
- package/lib/types/plugin.d.ts +29 -0
- package/lib/types/process-shutdown.d.ts +20 -0
- package/lib/types/profile-boot.d.ts +92 -0
- package/lib/types/startup-diagnostics.d.ts +19 -0
- package/package.json +171 -3
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { t as INSTALL_ANCHOR } from "./profile-boot-DSy6PUHQ.js";
|
|
2
|
+
import { DEFAULT_PROFILE_BUNDLES, EngineNameCollisionError, PROFILE_TEMPLATES, doctorPluginPackage, initProfile, readProfileCompatibility, readProfileManifest, readProfilePluginRows, reconcileProfileBundles, resolveBundleDir, resolveProfileDir, setProfileVersionExemption } from "@qilin-agent/app-boot";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { withFileLock } from "@qilin-agent/atomic-write";
|
|
8
|
+
import { refusedInstallSpecs } from "@qilin-agent/plugin-manager/operations";
|
|
9
|
+
//#region lib/types/plugin.js
|
|
10
|
+
/**
|
|
11
|
+
* `qilin plugin [--profile <name>] <args...>` — profile plugin management.
|
|
12
|
+
*
|
|
13
|
+
* `list` and `doctor` are the launcher's own verbs and never mutate anything:
|
|
14
|
+
* `list` prints the profile's bundle layers, and `doctor` reports one plugin
|
|
15
|
+
* package's DSH-era compatibility. `version-exemptions`, `allow-version`, and
|
|
16
|
+
* `revoke-version` read and write the profile's own `compatibility.json` under
|
|
17
|
+
* its file lock and never become pnpm arguments. Every other argument list
|
|
18
|
+
* forwards to pnpm in the profile directory: refuse the packages it names that
|
|
19
|
+
* the running runtime version rejects, initialize the profile on first use, run
|
|
20
|
+
* `pnpm <args...>`, then reconcile the `qilin.profile.bundles` layer list
|
|
21
|
+
* against the installed state (a dependency resolving to a package that
|
|
22
|
+
* declares `qilin.bundle` joins the layer stack; a removed or bundle-less
|
|
23
|
+
* dependency leaves it). Reconciling by installed state, not by dependency
|
|
24
|
+
* diff, means `update` activates a package that gained its `qilin.bundle`
|
|
25
|
+
* declaration in a newer version. A profile that installed an upstream DSH-era
|
|
26
|
+
* engine package is refused before any layer changes, with the removal command
|
|
27
|
+
* in the diagnostic.
|
|
28
|
+
* @module @qilin-agent/cli/plugin
|
|
29
|
+
*/
|
|
30
|
+
const NAME = "qilin";
|
|
31
|
+
/** Bound on waiting for the profile write lock an exemption command shares with the manager. */
|
|
32
|
+
const LOCK_WAIT_MS = 12e4;
|
|
33
|
+
/** Bound on the pre-install registry lookup that reads a named package's peers. */
|
|
34
|
+
const LOOKUP_TIMEOUT_MS = 12e4;
|
|
35
|
+
/**
|
|
36
|
+
* Print one profile's plugin layers in activation order.
|
|
37
|
+
* @param profile - the profile name, used to recognize its shipped template layers.
|
|
38
|
+
* @param dir - the profile directory.
|
|
39
|
+
* @returns the process exit code.
|
|
40
|
+
*/
|
|
41
|
+
function listPlugins(profile, dir) {
|
|
42
|
+
const rows = readProfilePluginRows(NAME, dir, INSTALL_ANCHOR, [...PROFILE_TEMPLATES[profile]?.bundles ?? []]);
|
|
43
|
+
if (rows.length === 0) {
|
|
44
|
+
process.stdout.write(`${NAME}: profile ${profile} lists no plugin layers\n`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
const locked = row.removable ? "" : " (shipped)";
|
|
49
|
+
process.stdout.write(`${String(row.layer)}\t${row.name}@${row.version ?? "not installed"}\t${row.source}${locked}\n`);
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve one doctor target: an existing package directory, or a package name
|
|
55
|
+
* installed in the profile.
|
|
56
|
+
* @param profile - the profile name, named in the failure diagnostic.
|
|
57
|
+
* @param profileDir - the profile directory.
|
|
58
|
+
* @param target - the argument as written.
|
|
59
|
+
* @returns the absolute package directory.
|
|
60
|
+
* @throws when neither a directory nor an installed package matches.
|
|
61
|
+
*/
|
|
62
|
+
function resolveDoctorTarget(profile, profileDir, target) {
|
|
63
|
+
const directory = resolve(target);
|
|
64
|
+
if (existsSync(join(directory, "package.json"))) return directory;
|
|
65
|
+
try {
|
|
66
|
+
return resolveBundleDir(NAME, target, INSTALL_ANCHOR, profileDir);
|
|
67
|
+
} catch (_notInstalled) {
|
|
68
|
+
throw new Error(`${NAME}: ${JSON.stringify(target)} is neither a package directory nor an installed package in profile ${profile}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Report one plugin package's DSH-era compatibility.
|
|
73
|
+
* @param profile - the profile name a package-name target resolves within.
|
|
74
|
+
* @param profileDir - the profile directory.
|
|
75
|
+
* @param target - the package name or directory from the command line.
|
|
76
|
+
* @returns 1 when the report found a blocking problem, otherwise 0.
|
|
77
|
+
*/
|
|
78
|
+
function doctorPlugin(profile, profileDir, target) {
|
|
79
|
+
const report = doctorPluginPackage(NAME, resolveDoctorTarget(profile, profileDir, target), INSTALL_ANCHOR);
|
|
80
|
+
process.stdout.write(`${report.name}\t${report.verdict}\t${report.dir}\n`);
|
|
81
|
+
for (const finding of report.findings) process.stdout.write(` ${finding.severity}\t${finding.check}\t${finding.message}\n`);
|
|
82
|
+
if (report.findings.length === 0) process.stdout.write(" no compatibility findings\n");
|
|
83
|
+
return report.verdict === "unusable" ? 1 : 0;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse and run the launcher's exact-version exemption commands.
|
|
87
|
+
*
|
|
88
|
+
* Only these three verbs are the launcher's; every other argument list stays
|
|
89
|
+
* pnpm's. `--accept-risk` is the operator's acknowledgement that the waived
|
|
90
|
+
* version may crash the application or corrupt data, and a grant may name only
|
|
91
|
+
* the running runtime version, so an approval can never be pre-granted for a
|
|
92
|
+
* future one. Revocation may name a historical runtime, because withdrawing an
|
|
93
|
+
* approval after an upgrade must stay possible.
|
|
94
|
+
* @param profile - the profile name whose compatibility file is read or written.
|
|
95
|
+
* @param args - the subcommand and its arguments, verbatim from argv.
|
|
96
|
+
* @returns the process exit code, or undefined when the arguments are pnpm's.
|
|
97
|
+
*/
|
|
98
|
+
async function exemptionCommand(profile, args) {
|
|
99
|
+
const [command, ...rest] = args;
|
|
100
|
+
if (command !== "allow-version" && command !== "revoke-version" && command !== "version-exemptions") return void 0;
|
|
101
|
+
try {
|
|
102
|
+
let packageVersion;
|
|
103
|
+
let runtimeVersion;
|
|
104
|
+
let acceptRisk = false;
|
|
105
|
+
const remaining = rest.values();
|
|
106
|
+
for (const argument of remaining) if (argument === "--accept-risk" && command === "allow-version" && !acceptRisk) acceptRisk = true;
|
|
107
|
+
else if (argument === "--qilin-version" && runtimeVersion === void 0) runtimeVersion = remaining.next().value;
|
|
108
|
+
else if (argument.startsWith("--qilin-version=") && runtimeVersion === void 0) runtimeVersion = argument.slice(16);
|
|
109
|
+
else if (!argument.startsWith("-") && packageVersion === void 0) packageVersion = argument;
|
|
110
|
+
else throw new Error(`unexpected argument ${JSON.stringify(argument)}`);
|
|
111
|
+
if (command === "version-exemptions" && rest.length > 0) throw new Error(`usage: ${NAME} plugin version-exemptions`);
|
|
112
|
+
let request;
|
|
113
|
+
if (command !== "version-exemptions") {
|
|
114
|
+
if (packageVersion === void 0 || runtimeVersion === void 0) throw new Error(`usage: ${NAME} plugin ${command} <package@version> --qilin-version <exact>${command === "allow-version" ? " --accept-risk" : ""}`);
|
|
115
|
+
request = {
|
|
116
|
+
packageVersion,
|
|
117
|
+
runtimeVersion
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (command === "allow-version") process.stderr.write(`${NAME}: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and ${NAME} versions.\n`);
|
|
121
|
+
const dir = resolveProfileDir(profile);
|
|
122
|
+
await mkdir(dir, { recursive: true });
|
|
123
|
+
await withFileLock(join(dir, "package.json"), async () => {
|
|
124
|
+
if (!existsSync(join(dir, "package.json"))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES);
|
|
125
|
+
if (request === void 0) {
|
|
126
|
+
const { exemptions, warnings } = readProfileCompatibility(dir);
|
|
127
|
+
for (const warning of warnings) process.stderr.write(`${NAME}: warning: ${warning}\n`);
|
|
128
|
+
process.stdout.write(JSON.stringify(exemptions, void 0, 2) + "\n");
|
|
129
|
+
} else {
|
|
130
|
+
await setProfileVersionExemption(dir, request.packageVersion, request.runtimeVersion, command === "allow-version", acceptRisk);
|
|
131
|
+
process.stdout.write(`${NAME}: ${command === "allow-version" ? "allowed" : "revoked"} ${request.packageVersion} for ${NAME} ${request.runtimeVersion}\n`);
|
|
132
|
+
}
|
|
133
|
+
}, { waitMs: LOCK_WAIT_MS });
|
|
134
|
+
return 0;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
process.stderr.write(`${NAME}: ${String(error)}\n`);
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Rewrite relative filesystem specs against the user's invoking directory.
|
|
142
|
+
* pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
|
|
143
|
+
* (or their `file:`/`link:` forms) would silently resolve inside the profile
|
|
144
|
+
* — `add .` from a plugin checkout would self-link the profile. Absolute
|
|
145
|
+
* specs, registry names, and every other pnpm argument pass through
|
|
146
|
+
* untouched.
|
|
147
|
+
* @param argument - one pnpm argument, verbatim from argv.
|
|
148
|
+
* @param cwd - the directory `qilin` was invoked from.
|
|
149
|
+
* @returns the argument with a relative path spec anchored to `cwd`.
|
|
150
|
+
*/
|
|
151
|
+
function anchorPathSpec(argument, cwd) {
|
|
152
|
+
const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument);
|
|
153
|
+
if (match?.groups?.path === void 0) return argument;
|
|
154
|
+
return `${match.groups.prefix ?? ""}${resolve(cwd, match.groups.path)}`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Refuse an install command that names a package the running runtime version
|
|
158
|
+
* rejects, before pnpm writes anything.
|
|
159
|
+
*
|
|
160
|
+
* The check reads the named manifests without installing them, so a rejected
|
|
161
|
+
* package never lands on disk and the version already in use keeps working. The
|
|
162
|
+
* post-install and startup checks still judge what a path or registry lookup
|
|
163
|
+
* cannot: a git or tarball spec, and a bundle component whose peers need the
|
|
164
|
+
* installed tree.
|
|
165
|
+
* @param profile - the profile name the operation runs against.
|
|
166
|
+
* @param dir - the profile directory.
|
|
167
|
+
* @param args - pnpm arguments, before relative path anchoring.
|
|
168
|
+
* @returns the process exit code when the command was refused, otherwise undefined.
|
|
169
|
+
*/
|
|
170
|
+
async function refuseIncompatibleInstall(profile, dir, args) {
|
|
171
|
+
const refused = await refusedInstallSpecs({
|
|
172
|
+
profile,
|
|
173
|
+
dir,
|
|
174
|
+
installAnchor: INSTALL_ANCHOR,
|
|
175
|
+
cwd: process.cwd()
|
|
176
|
+
}, args, {
|
|
177
|
+
execution: "cli",
|
|
178
|
+
lookupTimeoutMs: LOOKUP_TIMEOUT_MS
|
|
179
|
+
});
|
|
180
|
+
if (refused.warnings.length === 0) return void 0;
|
|
181
|
+
process.stderr.write(`\n${NAME}: installation rejected: ${refused.warnings.join("\n")}\n${NAME}: nothing was installed.\n`);
|
|
182
|
+
for (const { name, version, runtimeVersion } of refused.incompatible) process.stderr.write(`${NAME}: to accept the risk, run: ${NAME} plugin --profile ${profile} allow-version ${name}@${version} --qilin-version ${runtimeVersion} --accept-risk\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Run one `qilin plugin` invocation: report or list, grant or revoke one
|
|
187
|
+
* exact-version exemption, or forward to pnpm and reconcile.
|
|
188
|
+
* @param profile - the profile name.
|
|
189
|
+
* @param args - the subcommand and its arguments, or pnpm arguments with relative path specs anchored to the invoking directory.
|
|
190
|
+
* @returns the process exit code, or 1 when a report, an exemption, or the reconcile refused the profile.
|
|
191
|
+
*/
|
|
192
|
+
async function runPlugin(profile, args) {
|
|
193
|
+
const dir = resolveProfileDir(profile);
|
|
194
|
+
if (args[0] === "list") return listPlugins(profile, dir);
|
|
195
|
+
if (args[0] === "doctor") try {
|
|
196
|
+
return doctorPlugin(profile, dir, args[1]);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
process.stderr.write(`${error.message}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const exemption = await exemptionCommand(profile, args);
|
|
202
|
+
if (exemption !== void 0) return exemption;
|
|
203
|
+
const refused = await refuseIncompatibleInstall(profile, dir, args);
|
|
204
|
+
if (refused !== void 0) return refused;
|
|
205
|
+
if (!existsSync(join(dir, "package.json"))) {
|
|
206
|
+
const template = PROFILE_TEMPLATES[profile];
|
|
207
|
+
initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES);
|
|
208
|
+
process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`);
|
|
209
|
+
}
|
|
210
|
+
for (const warning of readProfileCompatibility(dir).warnings) process.stderr.write(`${NAME}: warning: ${warning}\n`);
|
|
211
|
+
const before = readProfileManifest(NAME, dir);
|
|
212
|
+
const result = spawnSync("pnpm", args.map((argument) => anchorPathSpec(argument, process.cwd())), {
|
|
213
|
+
cwd: dir,
|
|
214
|
+
stdio: "inherit",
|
|
215
|
+
shell: process.platform === "win32"
|
|
216
|
+
});
|
|
217
|
+
if (result.error !== void 0) {
|
|
218
|
+
if (result.error.code === "ENOENT") {
|
|
219
|
+
process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`);
|
|
220
|
+
return 127;
|
|
221
|
+
}
|
|
222
|
+
throw result.error;
|
|
223
|
+
}
|
|
224
|
+
const exitCode = result.status ?? 1;
|
|
225
|
+
if (exitCode === 0) try {
|
|
226
|
+
reconcileProfileBundles(NAME, before, dir, INSTALL_ANCHOR);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (!(error instanceof EngineNameCollisionError)) throw error;
|
|
229
|
+
process.stderr.write(`${error.message}\n`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`);
|
|
234
|
+
if (args.some((argument) => /^git\+|^github:|\.git(?:#|$)/.test(argument))) process.stderr.write(`${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(dir, "pnpm-workspace.yaml")}, then re-run\n`);
|
|
235
|
+
}
|
|
236
|
+
return exitCode;
|
|
237
|
+
}
|
|
238
|
+
//#endregion
|
|
239
|
+
export { runPlugin };
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PluginPackages, QILIN_LAUNCH_PROFILE_KEY, boot, createProfileResolutionGeneration, healIsolatedProfileModuleFallback, healProfilesModuleFallback, initProfile, installFailLoud, loadOverlayPatches, loadProfile, loadProfileDirectory, readProfilePatches, resolveProfileDir } from "@qilin-agent/app-boot";
|
|
2
|
+
import { resolveQilinHome } from "@qilin-agent/home-paths";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { installProxyFromEnvironment } from "@qilin-agent/http-proxy";
|
|
7
|
+
import { QILIN_LAUNCH_ENVIRONMENT_KEY } from "@qilin-agent/launch-environment";
|
|
8
|
+
import { provideCmdline } from "@qilin-agent/cmdline";
|
|
9
|
+
//#region lib/types/process-shutdown.js
|
|
10
|
+
/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
|
|
11
|
+
/** Maximum grace allowed for the application tree to dispose before process exit. */
|
|
12
|
+
const PROCESS_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
13
|
+
/**
|
|
14
|
+
* Create one process-exit controller around an application disposer.
|
|
15
|
+
* @param dispose - Whole-application teardown that resolves at quiescence.
|
|
16
|
+
* @param forceExit - Function that exits the process immediately, replaceable by tests.
|
|
17
|
+
* @param complete - Function that records the natural completion code, replaceable by tests.
|
|
18
|
+
* @param timeoutMs - Grace before forced exit, replaceable by tests.
|
|
19
|
+
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
|
|
20
|
+
*/
|
|
21
|
+
function createProcessShutdown(dispose, forceExit = (code) => {
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}, complete = (code) => {
|
|
24
|
+
process.exitCode = code;
|
|
25
|
+
}, timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS) {
|
|
26
|
+
let pending;
|
|
27
|
+
let timeout;
|
|
28
|
+
let completed = false;
|
|
29
|
+
let forceExited = false;
|
|
30
|
+
const clearExitTimeout = () => {
|
|
31
|
+
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
|
|
32
|
+
if (timeout !== void 0) clearTimeout(timeout);
|
|
33
|
+
};
|
|
34
|
+
const forceExitOnce = (code) => {
|
|
35
|
+
if (forceExited) return;
|
|
36
|
+
forceExited = true;
|
|
37
|
+
clearExitTimeout();
|
|
38
|
+
forceExit(code);
|
|
39
|
+
};
|
|
40
|
+
const completeOnce = (code) => {
|
|
41
|
+
if (completed || forceExited) return;
|
|
42
|
+
completed = true;
|
|
43
|
+
clearExitTimeout();
|
|
44
|
+
complete(code);
|
|
45
|
+
};
|
|
46
|
+
const start = (code, forceAfterDispose) => {
|
|
47
|
+
if (pending !== void 0) return pending;
|
|
48
|
+
timeout = setTimeout(() => {
|
|
49
|
+
forceExitOnce(code);
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
pending = Promise.resolve().then(dispose).then(() => {
|
|
52
|
+
if (forceAfterDispose) forceExitOnce(code);
|
|
53
|
+
else completeOnce(code);
|
|
54
|
+
}, () => {
|
|
55
|
+
forceExitOnce(code);
|
|
56
|
+
});
|
|
57
|
+
return pending;
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
shutdown(code) {
|
|
61
|
+
return start(code, false);
|
|
62
|
+
},
|
|
63
|
+
interrupt(code) {
|
|
64
|
+
if (pending !== void 0) {
|
|
65
|
+
forceExitOnce(code);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
start(code, true);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region lib/types/profile-boot.js
|
|
74
|
+
/**
|
|
75
|
+
* Shared profile boot for every `qilin` surface: resolve the profile, stack its
|
|
76
|
+
* patch layers (bundle layers in `qilin.profile.bundles` order, the profile's
|
|
77
|
+
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
|
|
78
|
+
* tree over the profile's empty root config, and wire fail-loud plus bounded shutdown.
|
|
79
|
+
*
|
|
80
|
+
* App flags are not the launcher's business: the invocation's inner arguments
|
|
81
|
+
* are provided to the tree through `ctx.cmdlineArgs`, where any injected app
|
|
82
|
+
* plugin may read the same immutable snapshot.
|
|
83
|
+
* @module @qilin-agent/cli/profile-boot
|
|
84
|
+
*/
|
|
85
|
+
const NAME = "qilin";
|
|
86
|
+
/** Launcher-owned readiness signal committed only after boot and host setup succeed. */
|
|
87
|
+
function createAppReady() {
|
|
88
|
+
let ready = false;
|
|
89
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
90
|
+
return {
|
|
91
|
+
service: { onReady(listener) {
|
|
92
|
+
if (ready) {
|
|
93
|
+
listener();
|
|
94
|
+
return () => {};
|
|
95
|
+
}
|
|
96
|
+
listeners.add(listener);
|
|
97
|
+
return () => {
|
|
98
|
+
listeners.delete(listener);
|
|
99
|
+
};
|
|
100
|
+
} },
|
|
101
|
+
commit() {
|
|
102
|
+
if (ready) return;
|
|
103
|
+
ready = true;
|
|
104
|
+
for (const listener of [...listeners]) listener();
|
|
105
|
+
listeners.clear();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The home-level user patch layer (`$QILIN_HOME/cordis.patch.yml`), applied
|
|
111
|
+
* over every profile's own layer. Resolved per call, not at module load:
|
|
112
|
+
* `$QILIN_HOME` may be set by the test or launcher after import.
|
|
113
|
+
* @returns the absolute patch-file path.
|
|
114
|
+
*/
|
|
115
|
+
function homePatchPath() {
|
|
116
|
+
return join(resolveQilinHome(), PROFILE_PATCH_FILENAME);
|
|
117
|
+
}
|
|
118
|
+
/** Absolute path of this qilin installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
|
|
119
|
+
const INSTALL_ANCHOR = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
120
|
+
/** The empty root entry list every profile tree patches over. */
|
|
121
|
+
const PROFILE_ROOT_CONFIG = `# qilin profile root — an empty entry list. The tree is composed as patches:
|
|
122
|
+
# each bundle in package.json's qilin.profile.bundles, then cordis.patch.yml, then any
|
|
123
|
+
# --patch overlays. Edit cordis.patch.yml, not this file.
|
|
124
|
+
[]
|
|
125
|
+
`;
|
|
126
|
+
/** Root config filename inside a profile directory. */
|
|
127
|
+
const PROFILE_ROOT_FILENAME = "cordis.yml";
|
|
128
|
+
/**
|
|
129
|
+
* Initialize a missing profile from one shipped template. This copies only
|
|
130
|
+
* the template's bundle list; local state from the
|
|
131
|
+
* same-named shipped profile is not read, and no inheritance metadata is
|
|
132
|
+
* persisted. Shipped profile names are reserved, and the target directory is
|
|
133
|
+
* claimed exclusively so existing or concurrent state is never reused.
|
|
134
|
+
* @param name - the new profile name.
|
|
135
|
+
* @param fromDefaultProfile - shipped profile template to copy.
|
|
136
|
+
* @param home - Harness home containing the profile directory.
|
|
137
|
+
* @throws when the template is unknown, the target name is shipped, or the target directory exists.
|
|
138
|
+
*/
|
|
139
|
+
function initializeProfileFromDefault(name, fromDefaultProfile, home = resolveQilinHome()) {
|
|
140
|
+
const dir = resolveProfileDir(name, home);
|
|
141
|
+
const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile) ? PROFILE_TEMPLATES[fromDefaultProfile] : void 0;
|
|
142
|
+
if (template === void 0) {
|
|
143
|
+
const expected = Object.keys(PROFILE_TEMPLATES).sort().map((value) => JSON.stringify(value)).join(", ");
|
|
144
|
+
throw new Error(`${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`);
|
|
145
|
+
}
|
|
146
|
+
if (Object.hasOwn(PROFILE_TEMPLATES, name)) throw new Error(`${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; omit --from-default-profile to use it`);
|
|
147
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
148
|
+
try {
|
|
149
|
+
mkdirSync(dir);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error.code !== "EEXIST") throw error;
|
|
152
|
+
const manifestPath = join(dir, "package.json");
|
|
153
|
+
if (existsSync(manifestPath)) throw new Error(`${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; omit --from-default-profile to use it`);
|
|
154
|
+
throw new Error(`${NAME}: profile directory ${dir} already exists; choose an unused profile name`);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
initProfile(dir, template.bundles);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
try {
|
|
160
|
+
rmSync(dir, {
|
|
161
|
+
recursive: true,
|
|
162
|
+
force: true
|
|
163
|
+
});
|
|
164
|
+
} catch (cleanupError) {
|
|
165
|
+
throw new AggregateError([error, cleanupError], `${NAME}: profile initialization failed and ${dir} could not be removed`);
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Load a resolved profile for `name` and (re)write the empty root config. The
|
|
172
|
+
* root is always rewritten: the whole composition is patch layers, and the
|
|
173
|
+
* vendored Loader's tree write-back (a plugin self-disposing persists the
|
|
174
|
+
* current tree) can bake composed rows into this file — which would duplicate
|
|
175
|
+
* every bundle insert on the next boot. The file exists on disk only because
|
|
176
|
+
* the Loader needs a real include root to anchor `baseUrl` at the profile
|
|
177
|
+
* directory (the config dump anchors on the same file, so both compose over
|
|
178
|
+
* the identical base).
|
|
179
|
+
* @param name - the profile name.
|
|
180
|
+
* @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
|
|
181
|
+
* @param fromDefaultProfile - shipped template used once to initialize a missing profile.
|
|
182
|
+
* @returns the loaded profile.
|
|
183
|
+
* @throws when explicit initialization names an unknown template or an existing profile.
|
|
184
|
+
*/
|
|
185
|
+
function prepareProfile(name, userLayer = true, fromDefaultProfile) {
|
|
186
|
+
if (fromDefaultProfile !== void 0) initializeProfileFromDefault(name, fromDefaultProfile);
|
|
187
|
+
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, void 0, { userLayer });
|
|
188
|
+
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG);
|
|
189
|
+
return profile;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Load `name` and compose its effective patch stack: bundle layers in
|
|
193
|
+
* `qilin.profile.bundles` order (a base-backed profile gets the base bundle's
|
|
194
|
+
* platform-gated shell rows), the profile's user layer, the home-level user
|
|
195
|
+
* layer (`$QILIN_HOME/cordis.patch.yml` — machine-local preferences that apply
|
|
196
|
+
* to every profile, so it outranks the per-profile layer), `--patch` overlays,
|
|
197
|
+
* then the telemetry switch.
|
|
198
|
+
* @param name - the profile name.
|
|
199
|
+
* @param patchFiles - `--patch` overlay paths, in argv order.
|
|
200
|
+
* @param resolutionMode - runtime lookup, disk links, or dual verification of both.
|
|
201
|
+
* @param fromDefaultProfile - shipped template for a missing named profile.
|
|
202
|
+
* @param resolvedProfile - application-owned profile and installation.
|
|
203
|
+
* @returns the profile and its patch layers.
|
|
204
|
+
*/
|
|
205
|
+
async function composeProfile(name, patchFiles, resolutionMode, fromDefaultProfile, resolvedProfile) {
|
|
206
|
+
const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile);
|
|
207
|
+
if (resolvedProfile !== void 0) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG);
|
|
208
|
+
const resolutionOptions = {
|
|
209
|
+
installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
|
|
210
|
+
profile
|
|
211
|
+
};
|
|
212
|
+
if (resolvedProfile !== void 0 && resolutionMode !== "runtime") healIsolatedProfileModuleFallback(resolvedProfile);
|
|
213
|
+
return {
|
|
214
|
+
profile,
|
|
215
|
+
resolution: resolutionMode === "runtime" || resolvedProfile !== void 0 ? await createProfileResolutionGeneration(resolutionOptions) : await healProfilesModuleFallback(resolutionOptions),
|
|
216
|
+
overlays: patchFiles.flatMap((file) => loadOverlayPatches(NAME, resolve(file)))
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Boot one profile invocation end to end and leave process lifetime to the
|
|
221
|
+
* mounted plugins (or to a one-shot runner the composition mounts).
|
|
222
|
+
* @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
|
|
223
|
+
* @returns the settled root context and the shutdown controller.
|
|
224
|
+
* @throws after disposing startup resources; cleanup failures retain the original error.
|
|
225
|
+
*/
|
|
226
|
+
async function runProfile(options) {
|
|
227
|
+
const disposeProxy = await installProxyFromEnvironment(options.environment, (message) => {
|
|
228
|
+
process.stderr.write(`${NAME}: ${message}\n`);
|
|
229
|
+
});
|
|
230
|
+
const resolutionMode = process.pkg !== void 0 ? "runtime" : options.resolutionMode ?? "runtime";
|
|
231
|
+
const app = {};
|
|
232
|
+
let disposal;
|
|
233
|
+
const dispose = () => disposal ??= (async () => {
|
|
234
|
+
const failures = [];
|
|
235
|
+
for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) try {
|
|
236
|
+
await release();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
failures.push(error);
|
|
239
|
+
}
|
|
240
|
+
if (failures.length === 1) throw failures[0];
|
|
241
|
+
if (failures.length > 1) throw new AggregateError(failures, "qilin: profile cleanup failed");
|
|
242
|
+
})();
|
|
243
|
+
try {
|
|
244
|
+
const composed = await composeProfile(options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile);
|
|
245
|
+
const appReady = createAppReady();
|
|
246
|
+
const shutdown = createProcessShutdown(dispose);
|
|
247
|
+
const signalShutdown = new AbortController();
|
|
248
|
+
const interrupt = (code) => {
|
|
249
|
+
signalShutdown.abort();
|
|
250
|
+
shutdown.interrupt(code);
|
|
251
|
+
};
|
|
252
|
+
process.on("SIGTERM", () => {
|
|
253
|
+
interrupt(0);
|
|
254
|
+
});
|
|
255
|
+
process.on("SIGINT", () => {
|
|
256
|
+
interrupt(130);
|
|
257
|
+
});
|
|
258
|
+
installFailLoud(NAME, process, async () => {
|
|
259
|
+
await app.current?.fiber.dispose();
|
|
260
|
+
});
|
|
261
|
+
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME);
|
|
262
|
+
const profileContext = {
|
|
263
|
+
name: options.profile,
|
|
264
|
+
...options.packageManager === void 0 ? {} : { packageManager: options.packageManager },
|
|
265
|
+
dir: composed.profile.dir,
|
|
266
|
+
patchPath: composed.profile.patchPath,
|
|
267
|
+
installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
|
|
268
|
+
startedBundles: composed.profile.layers.map((layer) => layer.packageName),
|
|
269
|
+
cwd: process.cwd(),
|
|
270
|
+
home: resolveQilinHome(),
|
|
271
|
+
overlays: composed.overlays,
|
|
272
|
+
telemetryDisabledEnv: process.env.QILIN_TELEMETRY_DISABLED
|
|
273
|
+
};
|
|
274
|
+
const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext, composed.profile), async (hostCtx) => {
|
|
275
|
+
app.current = hostCtx;
|
|
276
|
+
hostCtx.provide(QILIN_LAUNCH_PROFILE_KEY, {
|
|
277
|
+
name: composed.profile.name,
|
|
278
|
+
dir: composed.profile.dir,
|
|
279
|
+
home: resolveQilinHome(),
|
|
280
|
+
installAnchor: profileContext.installAnchor,
|
|
281
|
+
builtInBundles: [...PROFILE_TEMPLATES[options.profile]?.bundles ?? []]
|
|
282
|
+
});
|
|
283
|
+
hostCtx.provide("profileContext", profileContext);
|
|
284
|
+
hostCtx.provide(QILIN_LAUNCH_ENVIRONMENT_KEY, options.environment);
|
|
285
|
+
await hostCtx.plugin(PluginPackages, resolutionMode === "link" ? {} : {
|
|
286
|
+
generation: composed.resolution,
|
|
287
|
+
behavior: resolutionMode === "dual" ? "verify" : "enforce",
|
|
288
|
+
recompute: async () => createProfileResolutionGeneration({
|
|
289
|
+
installAnchor: profileContext.installAnchor,
|
|
290
|
+
profile: options.resolvedProfile === void 0 ? loadProfile(NAME, options.profile, profileContext.installAnchor) : loadProfileDirectory(NAME, options.resolvedProfile.profile.dir, profileContext.installAnchor)
|
|
291
|
+
})
|
|
292
|
+
});
|
|
293
|
+
provideCmdline(hostCtx, {
|
|
294
|
+
args: options.args,
|
|
295
|
+
exit: (code) => void shutdown.shutdown(code),
|
|
296
|
+
ready: appReady.service
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
app.current = ctx;
|
|
300
|
+
if (!signalShutdown.signal.aborted && ctx.fiber.state === 2 && ctx.get("loader") !== void 0) appReady.commit();
|
|
301
|
+
return {
|
|
302
|
+
ctx,
|
|
303
|
+
shutdown
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
try {
|
|
307
|
+
await dispose();
|
|
308
|
+
} catch (cleanupError) {
|
|
309
|
+
throw new AggregateError([error, cleanupError], "qilin: profile startup and cleanup failed");
|
|
310
|
+
}
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
export { prepareProfile as a, initializeProfileFromDefault as i, PROFILE_ROOT_FILENAME as n, runProfile as o, homePatchPath as r, INSTALL_ANCHOR as t };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as prepareProfile, i as initializeProfileFromDefault, n as PROFILE_ROOT_FILENAME, o as runProfile, r as homePatchPath, t as INSTALL_ANCHOR } from "./profile-boot-DSy6PUHQ.js";
|
|
2
|
+
export { INSTALL_ANCHOR, PROFILE_ROOT_FILENAME, homePatchPath, initializeProfileFromDefault, prepareProfile, runProfile };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commander adapter for the `qilin` command line.
|
|
3
|
+
*
|
|
4
|
+
* The launcher parses only what it owns — which profile to boot, which extra
|
|
5
|
+
* patch overlays to apply, and the config dumps — and hands **everything after
|
|
6
|
+
* its own flags** to the booted tree verbatim, where injected app plugins parse
|
|
7
|
+
* their own flag families and print their own `--help` (see
|
|
8
|
+
* `@qilin-agent/cmdline`). Launcher flags therefore come first: the first
|
|
9
|
+
* token this parser does not recognize starts the inner arguments, so
|
|
10
|
+
* `qilin --profile tui --resume abc` boots the tui profile with `--resume abc`,
|
|
11
|
+
* and `qilin --profile web -h` prints the web app's help, not this one's.
|
|
12
|
+
*
|
|
13
|
+
* `qilin <name>` abbreviates `qilin --profile <name>`; `plugin` manages a profile's
|
|
14
|
+
* plugin dependencies by forwarding to pnpm.
|
|
15
|
+
* @module @qilin-agent/cli/args
|
|
16
|
+
*/
|
|
17
|
+
/** Boot a named profile and hand it the invocation's inner arguments. */
|
|
18
|
+
interface ProfileInvocation {
|
|
19
|
+
mode: 'profile';
|
|
20
|
+
profile: string;
|
|
21
|
+
/** Shipped template used once to initialize a missing profile. */
|
|
22
|
+
fromDefaultProfile?: string | undefined;
|
|
23
|
+
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
|
24
|
+
patches: string[];
|
|
25
|
+
/** Everything after the launcher's own flags, verbatim, for injected app plugins. */
|
|
26
|
+
args: string[];
|
|
27
|
+
}
|
|
28
|
+
/** Print a composed profile tree and exit without booting. */
|
|
29
|
+
interface DumpConfigInvocation {
|
|
30
|
+
mode: 'dump-config';
|
|
31
|
+
profile: string;
|
|
32
|
+
/** Shipped template used once to initialize a missing profile. */
|
|
33
|
+
fromDefaultProfile?: string | undefined;
|
|
34
|
+
/** Omit the profile's user layer and --patch overlays; print bundle layers only. */
|
|
35
|
+
defaultOnly: boolean;
|
|
36
|
+
patches: string[];
|
|
37
|
+
}
|
|
38
|
+
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
|
|
39
|
+
interface PluginInvocation {
|
|
40
|
+
mode: 'plugin';
|
|
41
|
+
profile: string;
|
|
42
|
+
/** Raw pnpm arguments, verbatim. */
|
|
43
|
+
args: string[];
|
|
44
|
+
}
|
|
45
|
+
/** The resolved `qilin` invocation. Help, version, and errors exit inside {@link parseQilinArgs}. */
|
|
46
|
+
export type QilinInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation;
|
|
47
|
+
/**
|
|
48
|
+
* Resolve argv into one invocation, or print and exit for help, version, or an
|
|
49
|
+
* error.
|
|
50
|
+
* @param argv - arguments after the Node binary and script.
|
|
51
|
+
* @param version - version string printed by `--version`.
|
|
52
|
+
* @returns the resolved invocation.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseQilinArgs(argv: readonly string[], version: string): QilinInvocation;
|
|
55
|
+
export {};
|
|
56
|
+
//# sourceMappingURL=args.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Command-line entry for qilin.
|
|
4
|
+
* @module @qilin-agent/cli/bin
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Run the public qilin command-line interface.
|
|
8
|
+
* @returns a promise that settles when the selected command mode finishes.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runCli(): Promise<void>;
|
|
11
|
+
//# sourceMappingURL=bin.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-dump entry for `qilin --profile <name> --dump-config`: compose the
|
|
3
|
+
* profile's patch layers through the include plugin's patch algorithm without
|
|
4
|
+
* booting or evaluating `!!js`, with one source layer per bundle, the
|
|
5
|
+
* profile's own patch file, and each `--patch` overlay.
|
|
6
|
+
* @module @qilin-agent/cli/dump-config
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Print a profile composition with comments naming each source file and patch layer.
|
|
10
|
+
* @param profile - the profile name.
|
|
11
|
+
* @param defaultOnly - omit the profile's user layer and `--patch` overlays
|
|
12
|
+
* (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
|
|
13
|
+
* never parsed).
|
|
14
|
+
* @param patches - `--patch` overlay paths, in argv order.
|
|
15
|
+
* @param fromDefaultProfile - shipped template used once to initialize a missing profile.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[], fromDefaultProfile?: string): void;
|
|
18
|
+
//# sourceMappingURL=dump-config.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `qilin plugin [--profile <name>] <args...>` — profile plugin management.
|
|
3
|
+
*
|
|
4
|
+
* `list` and `doctor` are the launcher's own verbs and never mutate anything:
|
|
5
|
+
* `list` prints the profile's bundle layers, and `doctor` reports one plugin
|
|
6
|
+
* package's DSH-era compatibility. `version-exemptions`, `allow-version`, and
|
|
7
|
+
* `revoke-version` read and write the profile's own `compatibility.json` under
|
|
8
|
+
* its file lock and never become pnpm arguments. Every other argument list
|
|
9
|
+
* forwards to pnpm in the profile directory: refuse the packages it names that
|
|
10
|
+
* the running runtime version rejects, initialize the profile on first use, run
|
|
11
|
+
* `pnpm <args...>`, then reconcile the `qilin.profile.bundles` layer list
|
|
12
|
+
* against the installed state (a dependency resolving to a package that
|
|
13
|
+
* declares `qilin.bundle` joins the layer stack; a removed or bundle-less
|
|
14
|
+
* dependency leaves it). Reconciling by installed state, not by dependency
|
|
15
|
+
* diff, means `update` activates a package that gained its `qilin.bundle`
|
|
16
|
+
* declaration in a newer version. A profile that installed an upstream DSH-era
|
|
17
|
+
* engine package is refused before any layer changes, with the removal command
|
|
18
|
+
* in the diagnostic.
|
|
19
|
+
* @module @qilin-agent/cli/plugin
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Run one `qilin plugin` invocation: report or list, grant or revoke one
|
|
23
|
+
* exact-version exemption, or forward to pnpm and reconcile.
|
|
24
|
+
* @param profile - the profile name.
|
|
25
|
+
* @param args - the subcommand and its arguments, or pnpm arguments with relative path specs anchored to the invoking directory.
|
|
26
|
+
* @returns the process exit code, or 1 when a report, an exemption, or the reconcile refused the profile.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runPlugin(profile: string, args: readonly string[]): Promise<number>;
|
|
29
|
+
//# sourceMappingURL=plugin.d.ts.map
|