Skip to content

Commit a4cb2db

Browse files
authored
[codex] fix config install state and Windows installer probing (#306)
* fix(xim): avoid implicit config install dirs * fix(installer): add windows release source probing * docs(xim): define repeatable config packages * chore(0.4.42): bump version for release
1 parent 1303544 commit a4cb2db

6 files changed

Lines changed: 611 additions & 53 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Config Package Repeatable Design
2+
3+
> Date: 2026-05-27
4+
> Status: design note for PR #306 and follow-up libxpkg/spec work
5+
6+
## Summary
7+
8+
`type = "config"` should mean a repeatable configuration procedure, not an installed package.
9+
10+
A config package may depend on normal packages and may execute configuration logic after those dependencies are available. It should not own install artifacts, should not create an implicit installed state, and should generally avoid registering itself through xvm. Its core value is to provide a reentrant configuration code path that can be run more than once safely.
11+
12+
This definition intentionally narrows `config` so package authors do not need to reason about install markers, versioned payload directories, uninstall snapshots, or xvm deregistration for a package that is only meant to configure an environment.
13+
14+
## Definition
15+
16+
A `config` package is:
17+
18+
- a repeatable configuration procedure;
19+
- dependency-aware;
20+
- reentrant and expected to tolerate repeated execution;
21+
- usually implemented with `config()`;
22+
- normally not uninstallable, because it has no owned install payload.
23+
24+
A `config` package is not:
25+
26+
- a package with its own installed payload;
27+
- a package that owns package payload files under `pkginfo.install_dir()`;
28+
- a package that should become installed because xlings created metadata;
29+
- an xvm registration unit;
30+
- a good place for irreversible or non-idempotent system mutation.
31+
32+
## Lifecycle Contract
33+
34+
Recommended shape:
35+
36+
```lua
37+
package = {
38+
spec = "1",
39+
name = "example-config",
40+
type = "config",
41+
xpm = {
42+
linux = {
43+
deps = { "some-tool" },
44+
["latest"] = { ref = "1.0.0" },
45+
["1.0.0"] = {},
46+
},
47+
},
48+
}
49+
50+
function config()
51+
-- Reentrant configuration logic only.
52+
return true
53+
end
54+
```
55+
56+
Lifecycle constraints:
57+
58+
- `install()` is discouraged for `type = "config"`.
59+
- `config()` is the primary entrypoint.
60+
- `installed()` is discouraged because config packages should not rely on a persistent installed state.
61+
- `uninstall()` is optional and should only exist when the config procedure creates a deliberate, reversible external side effect.
62+
- Hooks must be idempotent. Running `xlings install <config>` multiple times should not corrupt user or system state.
63+
- Hooks should use explicit paths such as user config paths, dependency paths, or `system.rundir()` when needed. They should not rely on `pkginfo.install_dir()` as durable package-owned storage.
64+
65+
## Dependency Semantics
66+
67+
Config packages may have dependencies.
68+
69+
Those dependencies are normal packages and keep their own install state, xvm state, payloads, and uninstall semantics. The config package itself does not inherit or proxy those states.
70+
71+
This allows patterns such as:
72+
73+
- install normal toolchain packages as dependencies;
74+
- run a config procedure that wires editor settings or project state;
75+
- rerun the config procedure after user changes without reinstalling the toolchain.
76+
77+
## XVM Semantics
78+
79+
Config packages should avoid `xvm.add()` as part of their own definition.
80+
81+
Reason: `xvm.add()` creates durable registration state. Durable registration implies a corresponding `xvm.remove()` and therefore an uninstall contract. That contradicts the narrow config definition: a repeatable configuration procedure without its own install/uninstall lifecycle.
82+
83+
If a package needs xvm registration, it should usually be one of these instead:
84+
85+
- a normal `package` that owns an installed payload and registers its programs;
86+
- a future explicit registration-oriented package type or policy;
87+
- a dependency whose own `config()` registers itself.
88+
89+
Short-term compatibility note: existing packages may still use `xvm.add()` from config hooks. The stricter rule should first be documented and linted in libxpkg/index tooling before being made a hard runtime error.
90+
91+
## Install State Semantics
92+
93+
For `type = "config"` in the current soft-constraint PR:
94+
95+
- xlings should not create `.xim-installed`;
96+
- xlings should not copy `.xpkg.lua` into the package install directory;
97+
- xlings should not treat xlings-owned metadata as evidence that the config package is installed.
98+
- an empty install directory is allowed by the existing hook flow and must not count as installed.
99+
100+
If a config hook deliberately writes files somewhere, those files are external configuration state, not package payload. The hook author is responsible for making that write idempotent.
101+
102+
If the package truly needs owned files, backups, snapshots, or versioned removal, it should not be modeled as a pure config package.
103+
104+
## Current PR #306 Evaluation
105+
106+
PR #306 already moves in the right direction:
107+
108+
- it skips the `.xim-installed` auto-stamp for config packages;
109+
- it skips the `.xpkg.lua` install-dir snapshot for config packages;
110+
- it tests that a no-payload config package runs repeatedly instead of being mistaken for installed.
111+
112+
Current PR #306 soft policy:
113+
114+
- keep existing hook and cwd semantics unchanged;
115+
- allow the generic flow to leave an empty install directory;
116+
- treat only non-empty author-created content as installed state;
117+
- avoid xlings-owned stamp/snapshot files for config packages;
118+
- keep xvm compatibility for now, but document that new config packages should avoid `xvm.add()`.
119+
120+
This keeps the code change small while leaving room to refine the config contract in libxpkg/spec.
121+
122+
## Discussion Options
123+
124+
Option A: soft runtime policy, current PR scope.
125+
126+
- Keep hook semantics unchanged.
127+
- Allow an empty install directory.
128+
- Skip only xlings-owned install markers and snapshots for `type = "config"`.
129+
- Add TODO comments and tests.
130+
131+
Option B: stricter no-install-dir runtime policy.
132+
133+
- Do not precreate `install_dir` for config packages.
134+
- Do not run config hooks from `install_dir`.
135+
- This is cleaner theoretically, but it changes hook runtime behavior and may break existing recipes.
136+
137+
Option C: spec/lint first.
138+
139+
- Keep runtime compatibility.
140+
- Update libxpkg/spec and index checks to discourage `install()`, `installed()`, `pkginfo.install_dir()`, and `xvm.add()` in config packages.
141+
- Migrate existing packages gradually before any hard runtime behavior change.
142+
143+
Recommended now: Option A in PR #306, then Option C as follow-up. Option B should wait until existing package usage has been audited.
144+
145+
## Follow-Up Work
146+
147+
Spec and libxpkg should later make the contract explicit:
148+
149+
- update `docs/spec/xpkg-manifest-v1.md` with the narrow config definition;
150+
- add libxpkg/index lint checks:
151+
- warn when `type = "config"` defines `install()`;
152+
- warn when it calls `xvm.add()`;
153+
- warn when it uses `pkginfo.install_dir()`;
154+
- warn when it defines `installed()` as persistent install-state logic;
155+
- add migration guidance for existing config packages that are actually xvm registration or managed-state packages;
156+
- consider a future explicit package type or policy for registration/managed external state.
157+
158+
## Recommended Author Rule
159+
160+
Use `type = "config"` only when this sentence is true:
161+
162+
> This package is just a repeatable configuration procedure over its dependencies and environment; it has no owned install payload and no durable registration state of its own.
163+
164+
If that sentence is not true, use a normal package or introduce a more specific type/policy.

src/core/config.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import xlings.core.xvm.db;
1313
namespace xlings {
1414

1515
export struct Info {
16-
static constexpr std::string_view VERSION = "0.4.41";
16+
static constexpr std::string_view VERSION = "0.4.42";
1717
static constexpr std::string_view REPO = "https://github.com/openxlings/xlings";
1818
};
1919

src/core/xim/installer.cppm

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,7 +1407,12 @@ public:
14071407
// and skip the extracted-payload fallback (which exists for
14081408
// packages whose hook silently no-ops, e.g. patchelf where
14091409
// the tarball has no top-level dir).
1410-
if (!payloadInstalled) {
1410+
if (!payloadInstalled && node.pkgType != 3 /* Config */) {
1411+
// TODO(config): formalize config packages as repeatable,
1412+
// no-install procedures in libxpkg/spec. Keep current hook
1413+
// semantics for now; just avoid xlings-owned markers that
1414+
// would make an otherwise empty config directory look
1415+
// installed.
14111416
executor.apply_install_stamp_if_empty(ctx);
14121417
}
14131418

@@ -1472,15 +1477,22 @@ public:
14721477
continue;
14731478
}
14741479

1475-
if (auto snapshot = detail_::save_xpkg_snapshot_(node.pkgFile, ctx.install_dir);
1476-
!snapshot) {
1477-
log::error("failed to save xpkg snapshot for {}: {}",
1478-
node.name, snapshot.error());
1479-
if (onStatus) {
1480-
onStatus({ node.name, InstallPhase::Failed, 0.0f,
1481-
snapshot.error() });
1480+
if (node.pkgType != 3 /* Config */) {
1481+
// TODO(config): same soft policy as the install stamp. A
1482+
// config package with no author-created payload must not
1483+
// become installed solely because xlings copied metadata into
1484+
// install_dir. Future libxpkg/spec work should define the
1485+
// stricter config contract.
1486+
if (auto snapshot = detail_::save_xpkg_snapshot_(node.pkgFile, ctx.install_dir);
1487+
!snapshot) {
1488+
log::error("failed to save xpkg snapshot for {}: {}",
1489+
node.name, snapshot.error());
1490+
if (onStatus) {
1491+
onStatus({ node.name, InstallPhase::Failed, 0.0f,
1492+
snapshot.error() });
1493+
}
1494+
continue;
14821495
}
1483-
continue;
14841496
}
14851497

14861498
if (catalog_) {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env bash
2+
# E2E test: type=config packages that do not create install_dir themselves
3+
# must not be materialized by xlings.
4+
set -euo pipefail
5+
6+
# shellcheck source=./project_test_lib.sh
7+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh"
8+
9+
RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/config_install_no_implicit_dir"
10+
HOME_DIR="$RUNTIME_DIR/home"
11+
WORK_DIR="$RUNTIME_DIR/work"
12+
LOCAL_INDEX_DIR="$RUNTIME_DIR/xim-pkgindex"
13+
14+
cleanup() { rm -rf "$RUNTIME_DIR"; }
15+
trap cleanup EXIT
16+
cleanup
17+
18+
mkdir -p "$HOME_DIR/subos/default/bin" \
19+
"$WORK_DIR" \
20+
"$LOCAL_INDEX_DIR/pkgs/c"
21+
22+
cat > "$LOCAL_INDEX_DIR/pkgs/c/config-no-payload.lua" <<'LUA'
23+
package = {
24+
spec = "1",
25+
name = "config-no-payload",
26+
description = "Test fixture: config install hook does not create install_dir",
27+
type = "config",
28+
archs = {"x86_64", "aarch64"},
29+
status = "stable",
30+
xpm = {
31+
linux = { ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} },
32+
macosx = { ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} },
33+
windows = { ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} },
34+
},
35+
}
36+
37+
import("xim.libxpkg.system")
38+
39+
function install()
40+
local marker = path.join(system.rundir(), "install-count.txt")
41+
local count = 0
42+
if os.isfile(marker) then
43+
count = tonumber(io.readfile(marker)) or 0
44+
end
45+
io.writefile(marker, tostring(count + 1))
46+
return true
47+
end
48+
49+
function config()
50+
local marker = path.join(system.rundir(), "config-count.txt")
51+
local count = 0
52+
if os.isfile(marker) then
53+
count = tonumber(io.readfile(marker)) or 0
54+
end
55+
io.writefile(marker, tostring(count + 1))
56+
return true
57+
end
58+
LUA
59+
60+
printf 'xim_indexrepos = {}\n' > "$LOCAL_INDEX_DIR/xim-indexrepos.lua"
61+
62+
cat > "$HOME_DIR/.xlings.json" <<JSON
63+
{
64+
"version": "0.4.39",
65+
"activeSubos": "default",
66+
"mirror": "GLOBAL",
67+
"subos": {"default": {"dir": ""}},
68+
"index_repos": [
69+
{"name": "xim", "url": "$LOCAL_INDEX_DIR"}
70+
]
71+
}
72+
JSON
73+
74+
RUN() {
75+
( cd "$WORK_DIR" && env -u XLINGS_PROJECT_DIR XLINGS_HOME="$HOME_DIR" \
76+
"$(find_xlings_bin)" --verbose "$@" )
77+
}
78+
79+
RUN self init >/dev/null 2>&1 || fail "self init failed"
80+
mkdir -p "$HOME_DIR/data/xim-index-repos"
81+
printf '{}\n' > "$HOME_DIR/data/xim-index-repos/xim-indexrepos.json"
82+
83+
log "Installing config-no-payload twice..."
84+
RUN install config-no-payload -y >/dev/null 2>&1 \
85+
|| fail "first install failed"
86+
RUN install config-no-payload -y >/dev/null 2>&1 \
87+
|| fail "second install failed"
88+
89+
COUNT_FILE="$WORK_DIR/install-count.txt"
90+
[[ -f "$COUNT_FILE" ]] \
91+
|| fail "install hook did not write $COUNT_FILE"
92+
93+
COUNT="$(cat "$COUNT_FILE")"
94+
[[ "$COUNT" == "2" ]] \
95+
|| fail "install hook should run twice when it does not create install_dir; count=$COUNT"
96+
97+
CONFIG_COUNT_FILE="$WORK_DIR/config-count.txt"
98+
[[ -f "$CONFIG_COUNT_FILE" ]] \
99+
|| fail "config hook did not write $CONFIG_COUNT_FILE"
100+
101+
CONFIG_COUNT="$(cat "$CONFIG_COUNT_FILE")"
102+
[[ "$CONFIG_COUNT" == "2" ]] \
103+
|| fail "config hook should run twice without materializing install_dir; count=$CONFIG_COUNT"
104+
105+
INSTALL_DIR="$HOME_DIR/data/xpkgs/xim-x-config-no-payload/1.0.0"
106+
if [[ -e "$INSTALL_DIR" ]]; then
107+
[[ -d "$INSTALL_DIR" ]] \
108+
|| fail "config package install_dir exists but is not a directory: $INSTALL_DIR"
109+
[[ -z "$(find "$INSTALL_DIR" -mindepth 1 -print -quit)" ]] \
110+
|| fail "xlings implicitly materialized config package install_dir:
111+
$(ls -la "$INSTALL_DIR")"
112+
fi
113+
114+
log "PASS: config install hook without payload is not materialized by xlings"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5+
SCRIPT="$ROOT/tools/other/quick_install.ps1"
6+
7+
fail() {
8+
echo "FAIL: $*" >&2
9+
exit 1
10+
}
11+
12+
require_pattern() {
13+
local pattern="$1"
14+
local message="$2"
15+
grep -Eq "$pattern" "$SCRIPT" || fail "$message"
16+
}
17+
18+
reject_pattern() {
19+
local pattern="$1"
20+
local message="$2"
21+
if grep -Eq "$pattern" "$SCRIPT"; then
22+
fail "$message"
23+
fi
24+
}
25+
26+
require_pattern 'RESOURCE_REPO = "xlings-res/xlings"' 'Windows quick installer must probe xlings-res release resources'
27+
require_pattern 'GITHUB_API = "https://api\.github\.com"' 'Windows quick installer must support GitHub release metadata'
28+
require_pattern 'GITCODE_API = "https://api\.gitcode\.com/api/v5"' 'Windows quick installer must support GitCode xlings-res release metadata'
29+
require_pattern 'Measure-ReleaseLatency' 'Windows quick installer must probe release source latency'
30+
require_pattern 'Sort-ReleaseCandidates' 'Windows quick installer must prefer lower-latency release candidates'
31+
require_pattern 'Get-ReleaseAsset' 'Windows quick installer must select assets from release metadata'
32+
reject_pattern 'GITEE|gitee\.com|Gitee' 'Windows quick installer should only use GitHub and GitCode sources'
33+
reject_pattern 'GitHub xlings-res/xlings' 'Windows quick installer should not add a second GitHub resource source'
34+
reject_pattern '^[[:space:]]*exit[[:space:]]+[0-9]+' 'Windows quick installer must not call exit from irm|iex execution path'
35+
36+
echo "PASS: Windows quick install resource probing checks"

0 commit comments

Comments
 (0)