@homeflare/alchemy 0.19.1 → 0.21.0
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/README.md +25 -0
- package/dist/caddy/access-forward-auth.d.ts +56 -0
- package/dist/caddy/access-forward-auth.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +6 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +169 -75
- package/dist/caddy/index.js.map +10 -7
- package/dist/cloudflare/mcp-server-api.d.ts +135 -0
- package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
- package/dist/cloudflare/mcp-server-form.d.ts +116 -0
- package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/file-converge.d.ts +70 -0
- package/dist/launchd/file-converge.d.ts.map +1 -0
- package/dist/launchd/file-identity.d.ts +32 -0
- package/dist/launchd/file-identity.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +2 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +11 -24
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +173 -120
- package/dist/launchd/index.js.map +12 -10
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +17 -2
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +11 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/index.js +37 -11
- package/dist/linux/index.js.map +10 -10
- package/dist/linux/region.d.ts +11 -0
- package/dist/linux/region.d.ts.map +1 -1
- package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
- package/dist/linux/remote-file-plan.d.ts +22 -0
- package/dist/linux/remote-file-plan.d.ts.map +1 -1
- package/dist/linux/remote-file.d.ts +4 -1
- package/dist/linux/remote-file.d.ts.map +1 -1
- package/dist/linux/sudo-listing.d.ts +47 -0
- package/dist/linux/sudo-listing.d.ts.map +1 -0
- package/dist/linux/unit-handlers.d.ts.map +1 -1
- package/dist/linux/unit-preflight.d.ts +10 -1
- package/dist/linux/unit-preflight.d.ts.map +1 -1
- package/dist/proxmox/credential-literals.d.ts +1 -1
- package/dist/proxmox/credential-literals.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +7 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +7 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +16 -4
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +5 -2
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
- package/dist/release/archive.d.ts +11 -0
- package/dist/release/archive.d.ts.map +1 -0
- package/dist/release/binary-claim.d.ts +53 -0
- package/dist/release/binary-claim.d.ts.map +1 -0
- package/dist/release/binary-diff.d.ts +38 -0
- package/dist/release/binary-diff.d.ts.map +1 -0
- package/dist/release/binary-form.d.ts +118 -0
- package/dist/release/binary-form.d.ts.map +1 -0
- package/dist/release/binary-lifecycle.d.ts +55 -0
- package/dist/release/binary-lifecycle.d.ts.map +1 -0
- package/dist/release/binary-read.d.ts +11 -0
- package/dist/release/binary-read.d.ts.map +1 -0
- package/dist/release/binary.d.ts +68 -0
- package/dist/release/binary.d.ts.map +1 -0
- package/dist/release/catalog.d.ts +76 -0
- package/dist/release/catalog.d.ts.map +1 -0
- package/dist/release/checksums.d.ts +22 -0
- package/dist/release/checksums.d.ts.map +1 -0
- package/dist/release/declared-pins.d.ts +4 -0
- package/dist/release/declared-pins.d.ts.map +1 -0
- package/dist/release/download.d.ts +44 -0
- package/dist/release/download.d.ts.map +1 -0
- package/dist/release/fake-deploy.d.ts +48 -0
- package/dist/release/fake-deploy.d.ts.map +1 -0
- package/dist/release/fake-engine.d.ts +8 -0
- package/dist/release/fake-engine.d.ts.map +1 -0
- package/dist/release/fake-install.d.ts +39 -0
- package/dist/release/fake-install.d.ts.map +1 -0
- package/dist/release/fake-provider.d.ts +58 -0
- package/dist/release/fake-provider.d.ts.map +1 -0
- package/dist/release/fake-release.d.ts +95 -0
- package/dist/release/fake-release.d.ts.map +1 -0
- package/dist/release/index.d.ts +24 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +1365 -0
- package/dist/release/index.js.map +35 -0
- package/dist/release/providers.d.ts +19 -0
- package/dist/release/providers.d.ts.map +1 -0
- package/dist/release/refused.d.ts +39 -0
- package/dist/release/refused.d.ts.map +1 -0
- package/dist/release/tar.d.ts +14 -0
- package/dist/release/tar.d.ts.map +1 -0
- package/dist/release/victoria.d.ts +36 -0
- package/dist/release/victoria.d.ts.map +1 -0
- package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
- package/docs/adopted-deploys.md +13 -0
- package/docs/linux-host.md +5 -1
- package/docs/provider-standard.md +138 -0
- package/docs/release-binary-catalogs.md +113 -0
- package/docs/release-binary-measured.md +163 -0
- package/docs/release-binary-refusals.md +57 -0
- package/docs/release-binary-upstream.md +163 -0
- package/docs/release-binary.md +154 -0
- package/docs/upstream-conformance.md +164 -0
- package/package.json +5 -1
- package/src/caddy/access-forward-auth.test.ts +63 -0
- package/src/caddy/access-forward-auth.ts +120 -0
- package/src/caddy/index.ts +10 -0
- package/src/caddy/providers.test.ts +4 -0
- package/src/cloudflare/mcp-server-api.ts +294 -0
- package/src/cloudflare/mcp-server-form.ts +219 -0
- package/src/index.ts +1 -0
- package/src/launchd/file-converge.ts +233 -0
- package/src/launchd/file-identity.ts +69 -0
- package/src/launchd/host-file-alias.test.ts +65 -0
- package/src/launchd/host-file-form.ts +2 -1
- package/src/launchd/host-file-lifecycle.ts +25 -144
- package/src/launchd/host-file.ts +8 -2
- package/src/launchd/local-runner.test.ts +12 -1
- package/src/launchd/local-runner.ts +2 -0
- package/src/launchd/runner.ts +17 -2
- package/src/launchd/sudo-runner.ts +11 -0
- package/src/linux/region.ts +15 -0
- package/src/linux/remote-file-lifecycle.test.ts +0 -88
- package/src/linux/remote-file-lifecycle.ts +25 -7
- package/src/linux/remote-file-plan.ts +37 -1
- package/src/linux/remote-file-region.test.ts +181 -0
- package/src/linux/remote-file.ts +4 -1
- package/src/linux/sudo-listing.ts +122 -0
- package/src/linux/unit-handlers.ts +12 -5
- package/src/linux/unit-preflight.ts +20 -4
- package/src/linux/unit-rename.test.ts +76 -0
- package/src/proxmox/ceph-adopt.test.ts +210 -0
- package/src/proxmox/credential-literals.ts +1 -1
- package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
- package/src/proxmox/generated/pbs.ts +7 -0
- package/src/proxmox/generated/pve.ts +7 -0
- package/src/proxmox/index.test.ts +7 -4
- package/src/proxmox/index.ts +16 -4
- package/src/proxmox/pbs-notification-target-form.ts +1 -1
- package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
- package/src/proxmox/pbs-notification-target-wire.ts +1 -1
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/release/adopt-parity.test.ts +106 -0
- package/src/release/apply-pins.test.ts +128 -0
- package/src/release/archive.ts +94 -0
- package/src/release/binary-alias.test.ts +109 -0
- package/src/release/binary-claim.ts +80 -0
- package/src/release/binary-claims.test.ts +47 -0
- package/src/release/binary-diff.ts +73 -0
- package/src/release/binary-form.ts +229 -0
- package/src/release/binary-lifecycle.test.ts +90 -0
- package/src/release/binary-lifecycle.ts +202 -0
- package/src/release/binary-read.ts +55 -0
- package/src/release/binary-refusals.test.ts +192 -0
- package/src/release/binary-sudo.test.ts +93 -0
- package/src/release/binary.ts +181 -0
- package/src/release/catalog.test.ts +96 -0
- package/src/release/catalog.ts +175 -0
- package/src/release/checksums.test.ts +35 -0
- package/src/release/checksums.ts +43 -0
- package/src/release/declared-pins.ts +39 -0
- package/src/release/download.test.ts +138 -0
- package/src/release/download.ts +110 -0
- package/src/release/error-channel.test.ts +69 -0
- package/src/release/fake-deploy.ts +87 -0
- package/src/release/fake-engine.ts +50 -0
- package/src/release/fake-install.ts +59 -0
- package/src/release/fake-provider.ts +68 -0
- package/src/release/fake-release.ts +185 -0
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
- package/src/release/index.ts +41 -0
- package/src/release/plan.test.ts +189 -0
- package/src/release/provider-refusals.test.ts +48 -0
- package/src/release/providers.test.ts +174 -0
- package/src/release/providers.ts +24 -0
- package/src/release/recovery-read.test.ts +44 -0
- package/src/release/refused.ts +40 -0
- package/src/release/tar.test.ts +142 -0
- package/src/release/tar.ts +195 -0
- package/src/release/victoria.test.ts +108 -0
- package/src/release/victoria.ts +154 -0
- package/src/{proxmox → secrets}/write-only.ts +6 -0
- /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
- /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one path a declared file's bytes take onto a host, shared by every resource that owns ONE
|
|
3
|
+
* whole file: `Host.File` (text it was handed) and `Release.Binary` (bytes it downloaded and
|
|
4
|
+
* verified). Read, diff, converge and delete, as plain async functions over a HostRunner.
|
|
5
|
+
*
|
|
6
|
+
* ★ EXTRACTED FROM host-file-lifecycle.ts, NOT COPIED (2026-09-22). The ownership rules below — a
|
|
7
|
+
* file this resource does not own is never overwritten, a move onto an occupied path stays
|
|
8
|
+
* refused, a failed create is rolled back — were red-teamed on Host.File (docs/ownership.md). A
|
|
9
|
+
* second resource that writes a whole file gets them by calling this, so a fix lands in both.
|
|
10
|
+
* ★ THE BYTES ARE ASKED FOR ONLY WHEN A WRITE IS DUE (`bytesFor`). Host.File has them already; a
|
|
11
|
+
* binary has only a pinned digest until it downloads, and a host whose file already matches
|
|
12
|
+
* must not cost a 120 MB download to prove it.
|
|
13
|
+
* ⛔ SYMLINKS AND DIRECTORIES ARE REFUSED, NOT REPLACED. rename(2) over a symlink replaces the LINK,
|
|
14
|
+
* so a path that is a symlink (nix-darwin's /etc entries point into /nix/store) would be quietly
|
|
15
|
+
* taken from the tool that owns it — which puts it back, or refuses its next activation. Remove
|
|
16
|
+
* the path from its owner first, then declare it here.
|
|
17
|
+
*/
|
|
18
|
+
import type { Diff } from 'alchemy/Diff';
|
|
19
|
+
import { leaveOldPath, oneFile } from './file-identity.ts';
|
|
20
|
+
import type { HostFileAttributes } from './host-file-form.ts';
|
|
21
|
+
import { sha256Hex } from './job-form.ts';
|
|
22
|
+
import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
|
|
23
|
+
|
|
24
|
+
/** What a whole file must be: where, which bytes (by digest), which mode and owner. */
|
|
25
|
+
export type FileTarget = {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly sha256: string;
|
|
28
|
+
readonly mode: number;
|
|
29
|
+
readonly uid?: number;
|
|
30
|
+
readonly gid?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A family's refusal: `<Type> <path>: <message>`, so a deploy log names the resource that said no. */
|
|
34
|
+
export type Refuse = (path: string, message: string) => Error;
|
|
35
|
+
|
|
36
|
+
const numericId = (value: string | number): number | undefined =>
|
|
37
|
+
typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : undefined;
|
|
38
|
+
|
|
39
|
+
/** Resolve a declared owner and group (names or ids) to ids through the runner. */
|
|
40
|
+
export const resolveIds = async (
|
|
41
|
+
runner: HostRunner,
|
|
42
|
+
declared: { readonly owner?: string | number; readonly group?: string | number },
|
|
43
|
+
path: string,
|
|
44
|
+
refuse: Refuse,
|
|
45
|
+
): Promise<{ uid?: number; gid?: number }> => {
|
|
46
|
+
let uid: number | undefined;
|
|
47
|
+
let gid: number | undefined;
|
|
48
|
+
if (declared.owner !== undefined) {
|
|
49
|
+
uid = numericId(declared.owner) ?? (await runner.lookupUser(String(declared.owner)))?.uid;
|
|
50
|
+
if (uid === undefined) throw refuse(path, `no user ${String(declared.owner)} on this host`);
|
|
51
|
+
}
|
|
52
|
+
if (declared.group !== undefined) {
|
|
53
|
+
gid = numericId(declared.group) ?? (await runner.lookupGroup(String(declared.group)));
|
|
54
|
+
if (gid === undefined) throw refuse(path, `no group ${String(declared.group)} on this host`);
|
|
55
|
+
}
|
|
56
|
+
return { ...(uid === undefined ? {} : { uid }), ...(gid === undefined ? {} : { gid }) };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The file as it is now, or `undefined` when nothing is at the path. Non-files read with no digest. */
|
|
60
|
+
export const readFileAttributes = async (
|
|
61
|
+
runner: HostRunner,
|
|
62
|
+
path: string,
|
|
63
|
+
): Promise<HostFileAttributes | undefined> => {
|
|
64
|
+
const stat = await runner.stat(path);
|
|
65
|
+
if (stat === undefined) return undefined;
|
|
66
|
+
const bytes = stat.kind === 'file' ? await runner.readFile(path) : undefined;
|
|
67
|
+
return {
|
|
68
|
+
gid: stat.gid,
|
|
69
|
+
mode: stat.mode,
|
|
70
|
+
path,
|
|
71
|
+
sha256: bytes === undefined ? '' : sha256Hex(bytes),
|
|
72
|
+
size: stat.size,
|
|
73
|
+
uid: stat.uid,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const writeOptionsOf = (want: FileTarget): WriteOptions => ({
|
|
78
|
+
mode: want.mode,
|
|
79
|
+
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
80
|
+
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const fileMatches = (live: HostFileAttributes, want: FileTarget): boolean =>
|
|
84
|
+
live.sha256 === want.sha256 &&
|
|
85
|
+
live.mode === want.mode &&
|
|
86
|
+
(want.uid === undefined || live.uid === want.uid) &&
|
|
87
|
+
(want.gid === undefined || live.gid === want.gid);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
|
|
91
|
+
* refusals — a setuid or group-writable root file, a directory under a prefix another user may
|
|
92
|
+
* change — then fail the plan, before any resource is applied, instead of halfway through it.
|
|
93
|
+
*/
|
|
94
|
+
export const diffTarget = async (
|
|
95
|
+
runner: HostRunner,
|
|
96
|
+
want: FileTarget,
|
|
97
|
+
output: HostFileAttributes,
|
|
98
|
+
): Promise<Diff> => {
|
|
99
|
+
const writes = async (diff: Diff): Promise<Diff> => {
|
|
100
|
+
await runner.checkWrite?.(want.path, writeOptionsOf(want));
|
|
101
|
+
return diff;
|
|
102
|
+
};
|
|
103
|
+
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
104
|
+
// ⛔ …unless the two paths are ONE file (file-identity.ts). That is a respelling, not a move: a
|
|
105
|
+
// replace would let Phase 2 delete the old generation's path, which is the new one's file. An
|
|
106
|
+
// update records the new spelling, and convergeFile keeps the file.
|
|
107
|
+
if (want.path !== output.path) {
|
|
108
|
+
const same = await oneFile(runner, want.path, output.path);
|
|
109
|
+
return writes({ action: same === true ? 'update' : 'replace' });
|
|
110
|
+
}
|
|
111
|
+
if (want.sha256 !== output.sha256) return writes({ action: 'update' });
|
|
112
|
+
const live = await readFileAttributes(runner, want.path);
|
|
113
|
+
return live !== undefined && fileMatches(live, want)
|
|
114
|
+
? { action: 'noop' }
|
|
115
|
+
: writes({ action: 'update' });
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type Converge = {
|
|
119
|
+
readonly want: FileTarget;
|
|
120
|
+
/** The owner as declared (a name or an id), for the chown refusal's wording. */
|
|
121
|
+
readonly owner?: string | number | undefined;
|
|
122
|
+
/** The bytes to write, asked for only when a write is due; `before` is what is there now. */
|
|
123
|
+
readonly bytesFor: (before: HostFileAttributes | undefined) => Promise<Uint8Array>;
|
|
124
|
+
readonly refuse: Refuse;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Make the file at `want.path` match `want`, and return it as read back.
|
|
129
|
+
*
|
|
130
|
+
* `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
|
|
131
|
+
* own (ownership/adopt.ts adoptsAtApply). It lets such a generation take over a file already at the
|
|
132
|
+
* path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
|
|
133
|
+
* path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
|
|
134
|
+
* below), and a fresh replace's new generation, which the caller never marks adoptable (its
|
|
135
|
+
* `output` is undefined too, so this function cannot tell it from a create by itself).
|
|
136
|
+
*/
|
|
137
|
+
export const convergeFile = async (
|
|
138
|
+
runner: HostRunner,
|
|
139
|
+
spec: Converge,
|
|
140
|
+
output?: HostFileAttributes,
|
|
141
|
+
adopt = false,
|
|
142
|
+
): Promise<HostFileAttributes> => {
|
|
143
|
+
const { want, refuse } = spec;
|
|
144
|
+
const path = want.path;
|
|
145
|
+
// ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
|
|
146
|
+
if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
|
|
147
|
+
throw refuse(
|
|
148
|
+
path,
|
|
149
|
+
`owner ${String(spec.owner)} is not the deploying user; only root may chown. Deploy as root ` +
|
|
150
|
+
'or through a privileged HostRunner (sudoRunner() with this path under a prefix). This ' +
|
|
151
|
+
'provider never calls sudo itself.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const stat = await runner.stat(path);
|
|
155
|
+
if (stat !== undefined && stat.kind !== 'file') {
|
|
156
|
+
throw refuse(
|
|
157
|
+
path,
|
|
158
|
+
`is a ${stat.kind}; remove it from whatever owns it before declaring it here`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const before = stat === undefined ? undefined : await readFileAttributes(runner, path);
|
|
162
|
+
// ⚠️ An `output` at another path: the engine planned an UPDATE across a move, which it does
|
|
163
|
+
// when diff could not see the new path (host-file.ts). Finish it the way a replace would.
|
|
164
|
+
// ★ A respelling of ONE file (file-identity.ts) still counts as moved here, so a file there
|
|
165
|
+
// that does not match is refused below — never rewritten in place under the old spelling's pins.
|
|
166
|
+
const moved = output !== undefined && output.path !== path;
|
|
167
|
+
const prior = moved ? undefined : output;
|
|
168
|
+
/**
|
|
169
|
+
* ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
|
|
170
|
+
* the engine's adoption probe has already refused anything it found — except where it never
|
|
171
|
+
* looked: the new path of a replace, and a create whose props still held an Output at plan
|
|
172
|
+
* time. A path typo there would otherwise overwrite, say, a system file that a fresh
|
|
173
|
+
* declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
|
|
174
|
+
* create takes it over, as the probe would have let it (decided 2026-09-21).
|
|
175
|
+
*/
|
|
176
|
+
const takeOver = adopt && output === undefined;
|
|
177
|
+
if (prior === undefined && before !== undefined && !fileMatches(before, want) && !takeOver) {
|
|
178
|
+
throw refuse(
|
|
179
|
+
path,
|
|
180
|
+
'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
|
|
181
|
+
'deploy with --adopt.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (before === undefined || !fileMatches(before, want)) {
|
|
185
|
+
const bytes = await spec.bytesFor(before);
|
|
186
|
+
// ⛔ THE ONE WRITER NEVER WRITES BYTES THAT ARE NOT THE DECLARED ONES. Host.File's always are;
|
|
187
|
+
// a supplier that verifies elsewhere (a download) is held to the same digest here, last.
|
|
188
|
+
if (sha256Hex(bytes) !== want.sha256) {
|
|
189
|
+
throw refuse(path, `the bytes to write do not hash to ${want.sha256}. Nothing was written.`);
|
|
190
|
+
}
|
|
191
|
+
await runner.writeFileAtomic(path, bytes, writeOptionsOf(want));
|
|
192
|
+
}
|
|
193
|
+
// ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
|
|
194
|
+
// up here as a refusal instead of as a forever-`update`.
|
|
195
|
+
// ⚠️ Roll back a CREATE: left behind, the next plan's recovery `read` finds a file with no state,
|
|
196
|
+
// reports it `Unowned`, and every later deploy demands --adopt for our own file. ⛔ Also when
|
|
197
|
+
// the read-back THROWS: MEASURED 2026-09-22, a 0111 binary written as a non-root operator read
|
|
198
|
+
// back EACCES and stayed, and every later plan's probe failed with the same EACCES.
|
|
199
|
+
const rollBack = async () => {
|
|
200
|
+
if (before === undefined) await runner.removeFile(path).catch(() => undefined);
|
|
201
|
+
};
|
|
202
|
+
const after = await readFileAttributes(runner, path).catch(async (cause: unknown) => {
|
|
203
|
+
await rollBack();
|
|
204
|
+
throw refuse(path, `the write returned but reading it back failed: ${String(cause)}`);
|
|
205
|
+
});
|
|
206
|
+
if (after === undefined || !fileMatches(after, want)) {
|
|
207
|
+
await rollBack();
|
|
208
|
+
throw refuse(path, 'the write returned but the file on disk does not match the declaration');
|
|
209
|
+
}
|
|
210
|
+
// ★ Create-before-delete, as the replace would have been: the old path goes only once the new
|
|
211
|
+
// one is written and verified — and ⛔ never when the old path IS this file (file-identity.ts).
|
|
212
|
+
if (moved)
|
|
213
|
+
await leaveOldPath(
|
|
214
|
+
runner,
|
|
215
|
+
output.path,
|
|
216
|
+
path,
|
|
217
|
+
() => removeWholeFile(runner, output, refuse),
|
|
218
|
+
refuse,
|
|
219
|
+
);
|
|
220
|
+
return after;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Remove the file. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
|
|
224
|
+
export const removeWholeFile = async (
|
|
225
|
+
runner: HostRunner,
|
|
226
|
+
output: HostFileAttributes,
|
|
227
|
+
refuse: Refuse,
|
|
228
|
+
): Promise<void> => {
|
|
229
|
+
const stat = await runner.stat(output.path);
|
|
230
|
+
if (stat === undefined) return;
|
|
231
|
+
if (stat.kind !== 'file') throw refuse(output.path, `is now a ${stat.kind}; not removing it`);
|
|
232
|
+
await runner.removeFile(output.path);
|
|
233
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether two paths name ONE file — the question a move must answer before it deletes its old
|
|
3
|
+
* path, and a plan before it answers `replace`.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 MEASURED 2026-09-22 (release/binary-alias.test.ts, localRunner over a real temp directory,
|
|
6
|
+
* through Alchemy's own Plan and Apply): a Release.Binary whose directory was respelled to
|
|
7
|
+
* another path for the SAME file — through a symlinked parent (`/tmp` → `/private/tmp`, `/etc`
|
|
8
|
+
* → `/private/etc`), or by case alone on case-insensitive APFS, macOS's default — planned a
|
|
9
|
+
* `replace`. The new generation took the file already there as its own (same bytes, same mode:
|
|
10
|
+
* file-converge.ts's resume rule), then Phase 2 deleted the old generation's path, which was that
|
|
11
|
+
* same file. The deploy SUCCEEDED and the binary was gone. The `update` route lost it in one step:
|
|
12
|
+
* convergeFile's move removed the old path after "writing" the new one. Host.File shares both.
|
|
13
|
+
* ★ IDENTITY, NOT SPELLING. Case-folding the strings would be wrong on a case-sensitive volume and
|
|
14
|
+
* blind to a symlinked parent; device and inode from lstat are what the kernel itself compares.
|
|
15
|
+
* ⚠️ A HARD LINK also shares an inode. At the old path of a move it is therefore kept, not removed:
|
|
16
|
+
* one stray link left behind, rather than the risk of deleting the file just verified.
|
|
17
|
+
*/
|
|
18
|
+
import type { FileStat, HostRunner } from './runner.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `true` when both entries exist and are one file; `false` when either is absent or they differ;
|
|
22
|
+
* `undefined` when the runner reported no identity, so nobody can tell (fakes, custom runners).
|
|
23
|
+
*/
|
|
24
|
+
export const sameEntry = (
|
|
25
|
+
a: FileStat | undefined,
|
|
26
|
+
b: FileStat | undefined,
|
|
27
|
+
): boolean | undefined => {
|
|
28
|
+
if (a === undefined || b === undefined) return false;
|
|
29
|
+
if (a.dev === undefined || a.ino === undefined || b.dev === undefined || b.ino === undefined) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Whether `a` and `b` are one file on this host right now. Equal strings always are. */
|
|
36
|
+
export const oneFile = async (
|
|
37
|
+
runner: HostRunner,
|
|
38
|
+
a: string,
|
|
39
|
+
b: string,
|
|
40
|
+
): Promise<boolean | undefined> => {
|
|
41
|
+
if (a === b) return true;
|
|
42
|
+
const [left, right] = await Promise.all([runner.stat(a), runner.stat(b)]);
|
|
43
|
+
return sameEntry(left, right);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The last step of a move: `remove` the old path unless it is the file just verified at `path`.
|
|
48
|
+
* ⚠️ A runner that reports no identity cannot be asked beforehand, so `path` is checked after: a
|
|
49
|
+
* file lost to a respelling then fails the deploy instead of reporting success.
|
|
50
|
+
*/
|
|
51
|
+
export const leaveOldPath = async (
|
|
52
|
+
runner: HostRunner,
|
|
53
|
+
oldPath: string,
|
|
54
|
+
path: string,
|
|
55
|
+
remove: () => Promise<void>,
|
|
56
|
+
refuse: (path: string, message: string) => Error,
|
|
57
|
+
): Promise<void> => {
|
|
58
|
+
const same = await oneFile(runner, oldPath, path);
|
|
59
|
+
if (same === true) return;
|
|
60
|
+
await remove();
|
|
61
|
+
if (same === undefined && (await runner.stat(path)) === undefined) {
|
|
62
|
+
throw refuse(
|
|
63
|
+
path,
|
|
64
|
+
`removing the old path ${oldPath} removed this file too: they are one file under two ` +
|
|
65
|
+
'spellings (a symlinked parent, or case on a case-insensitive volume). Deploy again to ' +
|
|
66
|
+
'reinstall it, and keep one spelling.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host.File's diff while `content` is still an Output, over the REAL localRunner in a fresh temp
|
|
3
|
+
* directory: a new path is a replace, unless it is the old file under another spelling
|
|
4
|
+
* (file-identity.ts), where a replace's Phase 2 would delete the file the new generation took.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 The respelled case answered `replace` before the fix (2026-09-22). Nothing outside `mkdtemp`
|
|
7
|
+
* is touched; the diff only reads.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import { HostFile, HostFileProvider } from './host-file.ts';
|
|
16
|
+
import { localRunner } from './local-runner.ts';
|
|
17
|
+
import { hostRunnerLayer } from './runner.ts';
|
|
18
|
+
|
|
19
|
+
let tmp = '';
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmp = await mkdtemp(join(tmpdir(), 'hf-file-alias-'));
|
|
22
|
+
await mkdir(join(tmp, 'real'));
|
|
23
|
+
await writeFile(join(tmp, 'real', 'a.conf'), 'x');
|
|
24
|
+
await symlink(join(tmp, 'real'), join(tmp, 'link'));
|
|
25
|
+
});
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await rm(tmp, { force: true, recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const diffTo = (path: string) => {
|
|
31
|
+
const output = {
|
|
32
|
+
gid: 0,
|
|
33
|
+
mode: 0o644,
|
|
34
|
+
path: join(tmp, 'real', 'a.conf'),
|
|
35
|
+
sha256: '',
|
|
36
|
+
size: 1,
|
|
37
|
+
uid: 0,
|
|
38
|
+
};
|
|
39
|
+
const news = { content: Effect.succeed('x'), path } as never;
|
|
40
|
+
return Effect.runPromise(
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const diff = (yield* HostFile.Provider).diff;
|
|
43
|
+
if (diff === undefined) throw new Error('Host.File has no diff');
|
|
44
|
+
return yield* diff({
|
|
45
|
+
bindings: [] as never,
|
|
46
|
+
fqn: 'stack/conf',
|
|
47
|
+
id: 'conf',
|
|
48
|
+
instanceId: 'i-1',
|
|
49
|
+
newBindings: [] as never,
|
|
50
|
+
news,
|
|
51
|
+
oldBindings: [] as never,
|
|
52
|
+
olds: { content: 'x', path: output.path },
|
|
53
|
+
output,
|
|
54
|
+
} as never);
|
|
55
|
+
}).pipe(Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(localRunner()))))),
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
test('another file is still a replace', async () => {
|
|
60
|
+
expect(await diffTo(join(tmp, 'real', 'b.conf'))).toEqual({ action: 'replace' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('⛔ the same file through a symlinked parent is left to the engine (an update)', async () => {
|
|
64
|
+
expect(await diffTo(join(tmp, 'link', 'a.conf'))).toBeUndefined();
|
|
65
|
+
});
|
|
@@ -34,7 +34,8 @@ export interface HostFileAttributes {
|
|
|
34
34
|
|
|
35
35
|
export const DEFAULT_MODE = 0o644;
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/** A user or group as declared: a non-negative id, or a name the host could have. */
|
|
38
|
+
export const identityProblems = (name: string, value: string | number | undefined): string[] => {
|
|
38
39
|
if (value === undefined) return [];
|
|
39
40
|
if (typeof value === 'number') {
|
|
40
41
|
return Number.isSafeInteger(value) && value >= 0
|
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
* HostFile's read / diff / reconcile / delete as plain async functions over a HostRunner, so the
|
|
3
3
|
* lifecycle runs against fake-runner.ts in tests.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* ★ THE CONVERGENCE ITSELF IS file-converge.ts, shared with every resource that owns one whole file
|
|
6
|
+
* (Release.Binary is the second). What stays here is what makes a HOST FILE: text content, its
|
|
7
|
+
* validation (never a secret — host-file-form.ts), and the refusal wording `Host.File <path>: …`.
|
|
8
|
+
* ⛔ SYMLINKS AND DIRECTORIES ARE REFUSED, NOT REPLACED — see file-converge.ts for why.
|
|
9
9
|
*/
|
|
10
10
|
import type { Diff } from 'alchemy/Diff';
|
|
11
|
+
import {
|
|
12
|
+
type FileTarget,
|
|
13
|
+
convergeFile,
|
|
14
|
+
diffTarget,
|
|
15
|
+
removeWholeFile,
|
|
16
|
+
resolveIds,
|
|
17
|
+
} from './file-converge.ts';
|
|
11
18
|
import {
|
|
12
19
|
DEFAULT_MODE,
|
|
13
20
|
type HostFileAttributes,
|
|
@@ -15,106 +22,38 @@ import {
|
|
|
15
22
|
fileProblems,
|
|
16
23
|
} from './host-file-form.ts';
|
|
17
24
|
import { sha256Hex } from './job-form.ts';
|
|
18
|
-
import {
|
|
25
|
+
import type { HostRunner } from './runner.ts';
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
readonly bytes: Uint8Array;
|
|
22
|
-
readonly sha256: string;
|
|
23
|
-
readonly mode: number;
|
|
24
|
-
readonly uid?: number;
|
|
25
|
-
readonly gid?: number;
|
|
26
|
-
};
|
|
27
|
+
export { readFileAttributes } from './file-converge.ts';
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
type Desired = FileTarget & { readonly bytes: Uint8Array };
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
-
typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : undefined;
|
|
31
|
+
const refuse = (path: string, message: string): Error => new Error(`Host.File ${path}: ${message}`);
|
|
32
32
|
|
|
33
33
|
/** Validate, then resolve owner/group names to ids through the runner. */
|
|
34
34
|
export const desiredFile = async (runner: HostRunner, props: HostFileProps): Promise<Desired> => {
|
|
35
35
|
const found = fileProblems(props);
|
|
36
36
|
if (found.length > 0) throw refuse(props.path, found.join('; '));
|
|
37
37
|
const bytes = new TextEncoder().encode(props.content);
|
|
38
|
-
let uid: number | undefined;
|
|
39
|
-
let gid: number | undefined;
|
|
40
|
-
if (props.owner !== undefined) {
|
|
41
|
-
uid = numericId(props.owner) ?? (await runner.lookupUser(String(props.owner)))?.uid;
|
|
42
|
-
if (uid === undefined) throw refuse(props.path, `no user ${String(props.owner)} on this host`);
|
|
43
|
-
}
|
|
44
|
-
if (props.group !== undefined) {
|
|
45
|
-
gid = numericId(props.group) ?? (await runner.lookupGroup(String(props.group)));
|
|
46
|
-
if (gid === undefined) throw refuse(props.path, `no group ${String(props.group)} on this host`);
|
|
47
|
-
}
|
|
48
38
|
return {
|
|
49
39
|
bytes,
|
|
50
40
|
mode: props.mode ?? DEFAULT_MODE,
|
|
41
|
+
path: props.path,
|
|
51
42
|
sha256: sha256Hex(bytes),
|
|
52
|
-
...(
|
|
53
|
-
...(gid === undefined ? {} : { gid }),
|
|
54
|
-
};
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/** The file as it is now, or `undefined` when nothing is at the path. Non-files read with no digest. */
|
|
58
|
-
export const readFileAttributes = async (
|
|
59
|
-
runner: HostRunner,
|
|
60
|
-
path: string,
|
|
61
|
-
): Promise<HostFileAttributes | undefined> => {
|
|
62
|
-
const stat = await runner.stat(path);
|
|
63
|
-
if (stat === undefined) return undefined;
|
|
64
|
-
const bytes = stat.kind === 'file' ? await runner.readFile(path) : undefined;
|
|
65
|
-
return {
|
|
66
|
-
gid: stat.gid,
|
|
67
|
-
mode: stat.mode,
|
|
68
|
-
path,
|
|
69
|
-
sha256: bytes === undefined ? '' : sha256Hex(bytes),
|
|
70
|
-
size: stat.size,
|
|
71
|
-
uid: stat.uid,
|
|
43
|
+
...(await resolveIds(runner, props, props.path, refuse)),
|
|
72
44
|
};
|
|
73
45
|
};
|
|
74
46
|
|
|
75
|
-
|
|
76
|
-
mode: want.mode,
|
|
77
|
-
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
78
|
-
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const matches = (live: HostFileAttributes, want: Desired): boolean =>
|
|
82
|
-
live.sha256 === want.sha256 &&
|
|
83
|
-
live.mode === want.mode &&
|
|
84
|
-
(want.uid === undefined || live.uid === want.uid) &&
|
|
85
|
-
(want.gid === undefined || live.gid === want.gid);
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
|
|
89
|
-
* refusals — a setuid or group-writable root file, a directory under a prefix another user may
|
|
90
|
-
* change — then fail the plan, before any resource is applied, instead of halfway through it.
|
|
91
|
-
*/
|
|
47
|
+
/** Declared digest, mode and owner against state and the live file — see diffTarget. */
|
|
92
48
|
export const diffFile = async (
|
|
93
49
|
runner: HostRunner,
|
|
94
50
|
news: HostFileProps,
|
|
95
51
|
output: HostFileAttributes,
|
|
96
|
-
): Promise<Diff> =>
|
|
97
|
-
const want = await desiredFile(runner, news);
|
|
98
|
-
const writes = async (diff: Diff): Promise<Diff> => {
|
|
99
|
-
await runner.checkWrite?.(news.path, writeOptionsOf(want));
|
|
100
|
-
return diff;
|
|
101
|
-
};
|
|
102
|
-
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
103
|
-
if (news.path !== output.path) return writes({ action: 'replace' });
|
|
104
|
-
if (want.sha256 !== output.sha256) return writes({ action: 'update' });
|
|
105
|
-
const live = await readFileAttributes(runner, news.path);
|
|
106
|
-
return live !== undefined && matches(live, want)
|
|
107
|
-
? { action: 'noop' }
|
|
108
|
-
: writes({ action: 'update' });
|
|
109
|
-
};
|
|
52
|
+
): Promise<Diff> => diffTarget(runner, await desiredFile(runner, news), output);
|
|
110
53
|
|
|
111
54
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
|
|
115
|
-
* path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
|
|
116
|
-
* below), and a fresh replace's new generation, which the caller never marks adoptable (its
|
|
117
|
-
* `output` is undefined too, so this function cannot tell it from a create by itself).
|
|
55
|
+
* Write the declared text, unless the file already matches. `adopt` is adoptsAtApply's answer;
|
|
56
|
+
* what it may and may not take over is convergeFile's ⛔.
|
|
118
57
|
*/
|
|
119
58
|
export const reconcileFile = async (
|
|
120
59
|
runner: HostRunner,
|
|
@@ -123,68 +62,10 @@ export const reconcileFile = async (
|
|
|
123
62
|
adopt = false,
|
|
124
63
|
): Promise<HostFileAttributes> => {
|
|
125
64
|
const want = await desiredFile(runner, props);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
throw refuse(
|
|
129
|
-
props.path,
|
|
130
|
-
`owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` +
|
|
131
|
-
'or through a privileged HostRunner (sudoRunner() with this path under a prefix). This ' +
|
|
132
|
-
'provider never calls sudo itself.',
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
const stat = await runner.stat(props.path);
|
|
136
|
-
if (stat !== undefined && stat.kind !== 'file') {
|
|
137
|
-
throw refuse(
|
|
138
|
-
props.path,
|
|
139
|
-
`is a ${stat.kind}; remove it from whatever owns it before declaring it here`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
const before = stat === undefined ? undefined : await readFileAttributes(runner, props.path);
|
|
143
|
-
// ⚠️ An `output` at another path: the engine planned an UPDATE across a move, which it does
|
|
144
|
-
// when diff could not see the new path (host-file.ts). Finish it the way a replace would.
|
|
145
|
-
const moved = output !== undefined && output.path !== props.path;
|
|
146
|
-
const prior = moved ? undefined : output;
|
|
147
|
-
/**
|
|
148
|
-
* ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
|
|
149
|
-
* the engine's adoption probe has already refused anything it found — except where it never
|
|
150
|
-
* looked: the new path of a replace, and a create whose props still held an Output at plan
|
|
151
|
-
* time. A path typo there would otherwise overwrite, say, a system file that a fresh
|
|
152
|
-
* declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
|
|
153
|
-
* create takes it over, as the probe would have let it (decided 2026-09-21).
|
|
154
|
-
*/
|
|
155
|
-
const takeOver = adopt && output === undefined;
|
|
156
|
-
if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
|
|
157
|
-
throw refuse(
|
|
158
|
-
props.path,
|
|
159
|
-
'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
|
|
160
|
-
'deploy with --adopt.',
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
if (before === undefined || !matches(before, want)) {
|
|
164
|
-
await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
|
|
165
|
-
}
|
|
166
|
-
// ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
|
|
167
|
-
// up here as a refusal instead of as a forever-`update`.
|
|
168
|
-
const after = await readFileAttributes(runner, props.path);
|
|
169
|
-
if (after === undefined || !matches(after, want)) {
|
|
170
|
-
// ⚠️ Roll back a CREATE: left behind, the next plan's recovery `read` finds a file with no
|
|
171
|
-
// state, reports it `Unowned`, and every later deploy demands --adopt for our own file.
|
|
172
|
-
if (before === undefined) await runner.removeFile(props.path).catch(() => undefined);
|
|
173
|
-
throw refuse(
|
|
174
|
-
props.path,
|
|
175
|
-
'the write returned but the file on disk does not match the declaration',
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
// ★ Create-before-delete, as the replace would have been: the old path goes only once the new
|
|
179
|
-
// one is written and verified.
|
|
180
|
-
if (moved) await deleteFile(runner, output);
|
|
181
|
-
return after;
|
|
65
|
+
const spec = { bytesFor: async () => want.bytes, owner: props.owner, refuse, want };
|
|
66
|
+
return convergeFile(runner, spec, output, adopt);
|
|
182
67
|
};
|
|
183
68
|
|
|
184
69
|
/** Remove the file. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
|
|
185
|
-
export const deleteFile =
|
|
186
|
-
|
|
187
|
-
if (stat === undefined) return;
|
|
188
|
-
if (stat.kind !== 'file') throw refuse(output.path, `is now a ${stat.kind}; not removing it`);
|
|
189
|
-
await runner.removeFile(output.path);
|
|
190
|
-
};
|
|
70
|
+
export const deleteFile = (runner: HostRunner, output: HostFileAttributes): Promise<void> =>
|
|
71
|
+
removeWholeFile(runner, output, refuse);
|
package/src/launchd/host-file.ts
CHANGED
|
@@ -21,6 +21,7 @@ import * as Provider from 'alchemy/Provider';
|
|
|
21
21
|
import * as Effect from 'effect/Effect';
|
|
22
22
|
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
23
23
|
import { noteUnfinished } from '../ownership/resume.ts';
|
|
24
|
+
import { oneFile } from './file-identity.ts';
|
|
24
25
|
import { lift, resolvedString } from './host-effect.ts';
|
|
25
26
|
import type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
|
|
26
27
|
import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
|
|
@@ -54,9 +55,14 @@ export const HostFileProvider = () =>
|
|
|
54
55
|
// ⛔ A new path is a replace even while `content` is unresolved (an Output templated in):
|
|
55
56
|
// the engine's default would be an update, which writes the new path and never
|
|
56
57
|
// removes the old one. reconcileFile also defends, for a path that is an Output.
|
|
58
|
+
// ⛔ Unless the new path is the old FILE under another spelling (file-identity.ts):
|
|
59
|
+
// a replace's Phase 2 would delete it. The update then records the new spelling.
|
|
57
60
|
const path = resolvedString(news, 'path');
|
|
58
|
-
return Effect.succeed(
|
|
59
|
-
|
|
61
|
+
if (path === undefined || path === output.path) return Effect.succeed(undefined);
|
|
62
|
+
return lift(async () =>
|
|
63
|
+
(await oneFile(runner, path, output.path)) === true
|
|
64
|
+
? undefined
|
|
65
|
+
: { action: 'replace' as const },
|
|
60
66
|
);
|
|
61
67
|
},
|
|
62
68
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* outside `mkdtemp`; the only programs it spawns are echo/false and a read-only user lookup.
|
|
4
4
|
*/
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
6
|
-
import { mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { mkdir, mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { localRunner } from './local-runner.ts';
|
|
@@ -80,6 +80,17 @@ describe('read, stat, remove', () => {
|
|
|
80
80
|
expect((await runner.stat(dir))?.kind).toBe('directory');
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test('stat carries identity: one file under two spellings has one dev and ino', async () => {
|
|
84
|
+
await mkdir(join(dir, 'real'));
|
|
85
|
+
await writeFile(join(dir, 'real', 'f'), 'x');
|
|
86
|
+
await symlink(join(dir, 'real'), join(dir, 'link'));
|
|
87
|
+
const [a, b] = await Promise.all(
|
|
88
|
+
[join(dir, 'real', 'f'), join(dir, 'link', 'f')].map((p) => runner.stat(p)),
|
|
89
|
+
);
|
|
90
|
+
expect(a?.ino).toBeNumber();
|
|
91
|
+
expect([b?.dev, b?.ino]).toEqual([a?.dev, a?.ino]);
|
|
92
|
+
});
|
|
93
|
+
|
|
83
94
|
test('readFile returns the bytes; removeFile is idempotent', async () => {
|
|
84
95
|
await writeFile(join(dir, 'f'), 'hello');
|
|
85
96
|
expect(new TextDecoder().decode(await runner.readFile(join(dir, 'f')))).toBe('hello');
|
|
@@ -130,7 +130,9 @@ export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
|
|
|
130
130
|
try {
|
|
131
131
|
const stats = await lstat(path);
|
|
132
132
|
return {
|
|
133
|
+
dev: stats.dev,
|
|
133
134
|
gid: stats.gid,
|
|
135
|
+
ino: stats.ino,
|
|
134
136
|
kind: kindOf(stats),
|
|
135
137
|
mode: stats.mode & 0o7777,
|
|
136
138
|
size: stats.size,
|