@microck/canonfig 2.0.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/agent/agent-resolution.errors.js +42 -0
  4. package/dist/agent/agent-resolution.layer.js +204 -0
  5. package/dist/agent/agent-resolution.service.js +2259 -0
  6. package/dist/agent/agent-resolution.types.js +1 -0
  7. package/dist/agent/controlled-executor.js +704 -0
  8. package/dist/agent/harness-adapters.js +85 -0
  9. package/dist/cli/cli.js +618 -0
  10. package/dist/cli/exit-codes.js +28 -0
  11. package/dist/cli/follower-commands.js +3 -0
  12. package/dist/cli/render.js +56 -0
  13. package/dist/cli/source-commands.js +5 -0
  14. package/dist/domain/brand.js +29 -0
  15. package/dist/domain/identity.js +31 -0
  16. package/dist/domain/npm-package-spec.js +186 -0
  17. package/dist/domain/profile.js +950 -0
  18. package/dist/domain/recipe-versions.js +297 -0
  19. package/dist/domain/resource.js +259 -0
  20. package/dist/domain/synchronization.js +346 -0
  21. package/dist/enrollment/enrollment.errors.js +43 -0
  22. package/dist/enrollment/enrollment.layer.js +724 -0
  23. package/dist/enrollment/enrollment.service.js +3 -0
  24. package/dist/enrollment/enrollment.types.js +59 -0
  25. package/dist/enrollment/follower-client.js +585 -0
  26. package/dist/enrollment/source-server.js +313 -0
  27. package/dist/machine/linux.layer.js +1183 -0
  28. package/dist/machine/machine-state.errors.js +52 -0
  29. package/dist/machine/machine-state.service.js +3 -0
  30. package/dist/machine/machine-state.types.js +1 -0
  31. package/dist/machine/macos.layer.js +470 -0
  32. package/dist/machine/windows.layer.js +879 -0
  33. package/dist/profile/discovery.js +740 -0
  34. package/dist/profile/profile-catalog.errors.js +50 -0
  35. package/dist/profile/profile-catalog.layer.js +20 -0
  36. package/dist/profile/profile-catalog.service.js +7 -0
  37. package/dist/profile/profile-codec.js +153 -0
  38. package/dist/profile/publication.js +298 -0
  39. package/dist/profile/tool-catalog.js +384 -0
  40. package/dist/runtime/doctor.js +306 -0
  41. package/dist/runtime/layers.js +706 -0
  42. package/dist/runtime/main.js +38 -0
  43. package/dist/schedule/linux-schedule.js +24 -0
  44. package/dist/schedule/macos-schedule.js +25 -0
  45. package/dist/schedule/schedule-manager.errors.js +17 -0
  46. package/dist/schedule/schedule-manager.layer.js +205 -0
  47. package/dist/schedule/schedule-manager.service.js +3 -0
  48. package/dist/schedule/schedule-manager.types.js +114 -0
  49. package/dist/schedule/windows-schedule.js +25 -0
  50. package/dist/state/state-repository.errors.js +55 -0
  51. package/dist/state/state-repository.layer.js +1507 -0
  52. package/dist/state/state-repository.service.js +3 -0
  53. package/dist/state/state-repository.types.js +1 -0
  54. package/dist/state/state-schema.js +298 -0
  55. package/dist/synchronization/config-codec.js +97 -0
  56. package/dist/synchronization/executor.js +700 -0
  57. package/dist/synchronization/follower-orchestration.js +939 -0
  58. package/dist/synchronization/follower-sync-config.js +81 -0
  59. package/dist/synchronization/npm-artifact.js +670 -0
  60. package/dist/synchronization/planner.js +378 -0
  61. package/dist/synchronization/recovery.js +397 -0
  62. package/dist/synchronization/resource-executors.js +1198 -0
  63. package/dist/synchronization/resource-plans.js +645 -0
  64. package/dist/synchronization/synchronization.errors.js +102 -0
  65. package/dist/synchronization/synchronization.layer.js +97 -0
  66. package/dist/synchronization/synchronization.service.js +11 -0
  67. package/dist/synchronization/synchronization.types.js +1 -0
  68. package/package.json +66 -0
@@ -0,0 +1,52 @@
1
+ import { Schema } from "effect";
2
+ export class InvalidMachinePathError extends Schema.TaggedError()("InvalidMachinePathError", {
3
+ path: Schema.String,
4
+ message: Schema.String,
5
+ }) {
6
+ }
7
+ export class MachineFilesystemError extends Schema.TaggedError()("MachineFilesystemError", {
8
+ operation: Schema.String,
9
+ path: Schema.String,
10
+ message: Schema.String,
11
+ }) {
12
+ }
13
+ export class FileSizeLimitError extends Schema.TaggedError()("FileSizeLimitError", {
14
+ path: Schema.String,
15
+ maximumBytes: Schema.Number,
16
+ }) {
17
+ }
18
+ export class ExecutableNotFoundError extends Schema.TaggedError()("ExecutableNotFoundError", {
19
+ name: Schema.String,
20
+ }) {
21
+ }
22
+ export class ProcessStartError extends Schema.TaggedError()("ProcessStartError", {
23
+ executable: Schema.String,
24
+ message: Schema.String,
25
+ }) {
26
+ }
27
+ export class ProcessTimeoutError extends Schema.TaggedError()("ProcessTimeoutError", {
28
+ executable: Schema.String,
29
+ timeoutMilliseconds: Schema.Number,
30
+ }) {
31
+ }
32
+ export class ProcessOutputLimitError extends Schema.TaggedError()("ProcessOutputLimitError", {
33
+ executable: Schema.String,
34
+ maximumOutputBytes: Schema.Number,
35
+ }) {
36
+ }
37
+ export class HumanActionRequiredError extends Schema.TaggedError()("HumanActionRequiredError", {
38
+ action: Schema.String,
39
+ recovery: Schema.String,
40
+ }) {
41
+ }
42
+ export class CredentialStorageError extends Schema.TaggedError()("CredentialStorageError", {
43
+ operation: Schema.String,
44
+ reference: Schema.String,
45
+ message: Schema.String,
46
+ }) {
47
+ }
48
+ export class InvalidSchedulerJobError extends Schema.TaggedError()("InvalidSchedulerJobError", {
49
+ field: Schema.String,
50
+ message: Schema.String,
51
+ }) {
52
+ }
@@ -0,0 +1,3 @@
1
+ import { Context } from "effect";
2
+ export class MachineState extends Context.Service()("canonfig/machine/MachineState") {
3
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,470 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, lstat, readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, normalize, resolve } from "node:path";
5
+ import { Effect, Layer, Redacted, Schema } from "effect";
6
+ import { CredentialReference, } from "../domain/brand.js";
7
+ import { CredentialStorageError, HumanActionRequiredError, InvalidMachinePathError, InvalidSchedulerJobError, MachineFilesystemError, } from "./machine-state.errors.js";
8
+ import { MachineState } from "./machine-state.service.js";
9
+ import { linuxMachineStateLayer } from "./linux.layer.js";
10
+ const decode = Schema.decodeUnknownSync;
11
+ const environmentEntries = () => Object.entries(process.env).flatMap(([name, value]) => value === undefined ? [] : [{ name, value }]);
12
+ const environmentValue = (environment, name) => environment.find((entry) => entry.name === name)?.value;
13
+ const macosPath = (absolute) => ({
14
+ platform: "macos",
15
+ absolute: normalize(absolute),
16
+ });
17
+ const linuxPath = (path) => ({
18
+ platform: "linux",
19
+ absolute: path.absolute,
20
+ });
21
+ const requireMacosPath = (path) => path.platform === "macos"
22
+ ? Effect.succeed(linuxPath(path))
23
+ : Effect.fail(new InvalidMachinePathError({
24
+ path: path.absolute,
25
+ message: `expected a macOS path, received ${path.platform}`,
26
+ }));
27
+ const normalizedPath = (input, home) => {
28
+ if (input.path.length === 0 || input.path.includes("\0")) {
29
+ return Effect.fail(new InvalidMachinePathError({
30
+ path: input.path,
31
+ message: "path must not be empty or contain NUL bytes",
32
+ }));
33
+ }
34
+ if (input.base !== undefined && input.base.platform !== "macos") {
35
+ return Effect.fail(new InvalidMachinePathError({
36
+ path: input.path,
37
+ message: `relative macOS paths cannot use a ${input.base.platform} base`,
38
+ }));
39
+ }
40
+ const expanded = input.path === "~"
41
+ ? home
42
+ : input.path.startsWith("~/")
43
+ ? join(home, input.path.slice(2))
44
+ : input.path;
45
+ return Effect.succeed(macosPath(resolve(input.base?.absolute ?? process.cwd(), expanded)));
46
+ };
47
+ const xml = (value) => value
48
+ .replaceAll("&", "&")
49
+ .replaceAll("<", "&lt;")
50
+ .replaceAll(">", "&gt;")
51
+ .replaceAll("\"", "&quot;")
52
+ .replaceAll("'", "&apos;");
53
+ const validateSingleLine = (value, field) => value.trim().length > 0 && !/[\n\r\0]/u.test(value)
54
+ ? Effect.succeed(value)
55
+ : Effect.fail(new InvalidSchedulerJobError({
56
+ field,
57
+ message: `${field} must be non-empty, single-line, and contain no NUL bytes`,
58
+ }));
59
+ const launchdCalendar = (calendar) => {
60
+ if (calendar.kind === "systemd-on-calendar") {
61
+ return Effect.fail(new InvalidSchedulerJobError({
62
+ field: "calendar.kind",
63
+ message: "systemd calendar expressions are not supported by launchd",
64
+ }));
65
+ }
66
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/u.test(calendar.localTime)) {
67
+ return Effect.fail(new InvalidSchedulerJobError({
68
+ field: "calendar.localTime",
69
+ message: "local time must use 24-hour HH:mm format",
70
+ }));
71
+ }
72
+ const [hour, minute] = calendar.localTime.split(":");
73
+ const weekdays = {
74
+ Sun: 0,
75
+ Mon: 1,
76
+ Tue: 2,
77
+ Wed: 3,
78
+ Thu: 4,
79
+ Fri: 5,
80
+ Sat: 6,
81
+ };
82
+ const intervals = calendar.kind === "daily"
83
+ ? [undefined]
84
+ : calendar.weekdays.map((weekday) => weekdays[weekday]);
85
+ const rendered = intervals.map((weekday) => `<dict><key>Hour</key><integer>${Number(hour)}</integer>`
86
+ + `<key>Minute</key><integer>${Number(minute)}</integer>`
87
+ + (weekday === undefined ? "" : `<key>Weekday</key><integer>${weekday}</integer>`)
88
+ + "</dict>");
89
+ return Effect.succeed(rendered.length === 1
90
+ ? rendered[0]
91
+ : `<array>${rendered.join("")}</array>`);
92
+ };
93
+ const renderLaunchdJob = (job) => Effect.gen(function* () {
94
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/u.test(job.name)) {
95
+ return yield* new InvalidSchedulerJobError({
96
+ field: "name",
97
+ message: "job name must be a portable launchd label suffix",
98
+ });
99
+ }
100
+ yield* validateSingleLine(job.description, "description");
101
+ const executable = yield* requireMacosPath(job.executable);
102
+ const arguments_ = yield* Effect.forEach(job.arguments, (argument, index) => validateSingleLine(argument, `arguments[${index}]`));
103
+ const calendar = yield* launchdCalendar(job.calendar);
104
+ const label = `dev.canonfig.${job.name}`;
105
+ const programArguments = [executable.absolute, ...arguments_]
106
+ .map((argument) => `<string>${xml(argument)}</string>`)
107
+ .join("");
108
+ const definition = [
109
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
110
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
111
+ + "\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
112
+ "<plist version=\"1.0\"><dict>",
113
+ `<key>Label</key><string>${xml(label)}</string>`,
114
+ `<key>ProgramArguments</key><array>${programArguments}</array>`,
115
+ "<key>ProcessType</key><string>Background</string>",
116
+ `<key>StartCalendarInterval</key>${calendar}`,
117
+ "</dict></plist>",
118
+ "",
119
+ ].join("\n");
120
+ return {
121
+ platform: "macos",
122
+ mechanism: "launchd-user-agent",
123
+ serviceName: `${label}.plist`,
124
+ service: definition,
125
+ schedule: definition,
126
+ };
127
+ });
128
+ const keychainKey = (reference) => {
129
+ const prefix = "keychain:";
130
+ const value = String(reference);
131
+ if (!value.startsWith(prefix) || value.length === prefix.length) {
132
+ return Effect.fail(new CredentialStorageError({
133
+ operation: "resolve credential reference",
134
+ reference: value,
135
+ message: "credential reference is not owned by macOS Keychain",
136
+ }));
137
+ }
138
+ return Effect.succeed(value.slice(prefix.length));
139
+ };
140
+ export const macosMachineStateLayer = (options = {}) => {
141
+ const environment = options.environment ?? environmentEntries();
142
+ const home = environmentValue(environment, "HOME") ?? homedir();
143
+ const policy = options.credentialPolicy ?? { kind: "secure-store" };
144
+ const security = "/usr/bin/security";
145
+ const base = linuxMachineStateLayer({
146
+ credentialPolicy: policy,
147
+ environment,
148
+ beforeSafeRootMutation: options.beforeSafeRootMutation,
149
+ safeRootMutationStrategy: "portable",
150
+ });
151
+ return Layer.effect(MachineState, Effect.gen(function* () {
152
+ const machine = yield* MachineState;
153
+ const secureStoreAvailable = Effect.promise(() => options.credentialStoreAccess !== "unavailable"
154
+ && process.platform === "darwin"
155
+ ? access(security).then(() => true).catch(() => false)
156
+ : Promise.resolve(false));
157
+ const requireSecurity = Effect.gen(function* () {
158
+ if (yield* secureStoreAvailable)
159
+ return security;
160
+ return yield* new HumanActionRequiredError({
161
+ action: "configure macOS credential storage",
162
+ recovery: "Run on macOS with an unlocked login Keychain, or explicitly select the local-file credential policy.",
163
+ });
164
+ });
165
+ const runSecurity = (arguments_) => machine.runProcess({
166
+ executable: { platform: "linux", absolute: security },
167
+ arguments: arguments_,
168
+ timeoutMilliseconds: 5_000,
169
+ maximumOutputBytes: 1024 * 1024,
170
+ });
171
+ const launchAgents = join(home, "Library", "LaunchAgents");
172
+ const launchctl = "/bin/launchctl";
173
+ const launchDomain = `gui/${process.getuid?.() ?? 0}`;
174
+ const runLaunchctl = options.launchctlRunner
175
+ ?? ((arguments_) => machine.runProcess({
176
+ executable: { platform: "linux", absolute: launchctl },
177
+ arguments: arguments_,
178
+ timeoutMilliseconds: 10_000,
179
+ maximumOutputBytes: 1024 * 1024,
180
+ }));
181
+ const queryLaunchctlActive = (label, action, recovery) => runLaunchctl(["print", `${launchDomain}/${label}`]).pipe(Effect.flatMap((result) => {
182
+ if (result.exitCode === 0)
183
+ return Effect.succeed(true);
184
+ const output = Buffer.concat([
185
+ Buffer.from(result.standardOutput),
186
+ Buffer.from(result.standardError),
187
+ ]).toString("utf8");
188
+ if (/could not find service/iu.test(output))
189
+ return Effect.succeed(false);
190
+ return Effect.fail(new HumanActionRequiredError({ action, recovery }));
191
+ }));
192
+ const nativeScheduler = {
193
+ inspect: (expected) => {
194
+ const path = join(launchAgents, expected.serviceName);
195
+ return Effect.gen(function* () {
196
+ const stored = yield* Effect.tryPromise({
197
+ try: () => readFile(path, "utf8").catch((cause) => cause.code === "ENOENT" ? undefined : Promise.reject(cause)),
198
+ catch: (cause) => new MachineFilesystemError({
199
+ operation: "inspect launchd user agent",
200
+ path,
201
+ message: cause instanceof Error ? cause.message : String(cause),
202
+ }),
203
+ });
204
+ if (stored === undefined) {
205
+ return { installed: false, enabled: false, matches: false };
206
+ }
207
+ const label = expected.serviceName.slice(0, -".plist".length);
208
+ const active = yield* queryLaunchctlActive(label, "inspect the Canonfig launchd agent", "launchd inspection failed; sign in to the macOS graphical user session and retry.");
209
+ return {
210
+ installed: true,
211
+ enabled: active,
212
+ matches: stored === expected.schedule,
213
+ };
214
+ });
215
+ },
216
+ snapshot: (expected) => {
217
+ const path = join(launchAgents, expected.serviceName);
218
+ return Effect.gen(function* () {
219
+ const stored = yield* Effect.tryPromise({
220
+ try: () => readFile(path, "utf8").catch((cause) => cause.code === "ENOENT" ? undefined : Promise.reject(cause)),
221
+ catch: (cause) => new MachineFilesystemError({
222
+ operation: "snapshot launchd user agent",
223
+ path,
224
+ message: cause instanceof Error ? cause.message : String(cause),
225
+ }),
226
+ });
227
+ if (stored === undefined) {
228
+ return {
229
+ state: "absent",
230
+ platform: expected.platform,
231
+ mechanism: expected.mechanism,
232
+ serviceName: expected.serviceName,
233
+ };
234
+ }
235
+ const metadata = yield* Effect.tryPromise({
236
+ try: () => lstat(path),
237
+ catch: (cause) => new MachineFilesystemError({
238
+ operation: "snapshot launchd user agent",
239
+ path,
240
+ message: cause instanceof Error ? cause.message : String(cause),
241
+ }),
242
+ });
243
+ const label = expected.serviceName.slice(0, -".plist".length);
244
+ const active = yield* queryLaunchctlActive(label, "capture the Canonfig launchd agent", "launchd snapshot inspection failed; sign in to the macOS graphical user session and retry.");
245
+ return {
246
+ state: "present",
247
+ platform: expected.platform,
248
+ mechanism: expected.mechanism,
249
+ serviceName: expected.serviceName,
250
+ enabled: active,
251
+ active,
252
+ servicePresent: true,
253
+ schedulePresent: true,
254
+ service: stored,
255
+ schedule: stored,
256
+ serviceMode: metadata.mode & 0o777,
257
+ scheduleMode: metadata.mode & 0o777,
258
+ };
259
+ });
260
+ },
261
+ install: (definition) => {
262
+ const path = join(launchAgents, definition.serviceName);
263
+ return Effect.gen(function* () {
264
+ yield* machine.atomicWrite({
265
+ path: linuxPath({ platform: "macos", absolute: path }),
266
+ content: new TextEncoder().encode(definition.schedule),
267
+ mode: 0o600,
268
+ });
269
+ yield* runLaunchctl(["bootout", launchDomain, path]).pipe(Effect.ignore);
270
+ const result = yield* runLaunchctl(["bootstrap", launchDomain, path]);
271
+ if (result.exitCode !== 0) {
272
+ return yield* new HumanActionRequiredError({
273
+ action: "load the Canonfig launchd agent",
274
+ recovery: "Sign in to the macOS graphical user session and ensure launchd user agents are available, then retry.",
275
+ });
276
+ }
277
+ });
278
+ },
279
+ remove: (definition) => {
280
+ const path = join(launchAgents, definition.serviceName);
281
+ return Effect.gen(function* () {
282
+ yield* runLaunchctl(["bootout", launchDomain, path]).pipe(Effect.ignore);
283
+ yield* machine.removeFile({
284
+ path: linuxPath({ platform: "macos", absolute: path }),
285
+ });
286
+ });
287
+ },
288
+ restore: (expected, snapshot) => {
289
+ const path = join(launchAgents, expected.serviceName);
290
+ return Effect.gen(function* () {
291
+ yield* runLaunchctl(["bootout", launchDomain, path]).pipe(Effect.ignore);
292
+ if (snapshot.state === "absent") {
293
+ yield* machine.removeFile({
294
+ path: linuxPath({ platform: "macos", absolute: path }),
295
+ });
296
+ return;
297
+ }
298
+ if (!snapshot.servicePresent
299
+ || !snapshot.schedulePresent
300
+ || snapshot.service === undefined) {
301
+ return yield* new HumanActionRequiredError({
302
+ action: "restore the Canonfig launchd agent",
303
+ recovery: "The captured launchd plist was incomplete; inspect the user agent manually.",
304
+ });
305
+ }
306
+ yield* machine.atomicWrite({
307
+ path: linuxPath({ platform: "macos", absolute: path }),
308
+ content: new TextEncoder().encode(snapshot.service),
309
+ mode: snapshot.serviceMode ?? 0o600,
310
+ });
311
+ if (snapshot.enabled) {
312
+ const result = yield* runLaunchctl(["bootstrap", launchDomain, path]);
313
+ if (result.exitCode !== 0) {
314
+ return yield* new HumanActionRequiredError({
315
+ action: "load the restored Canonfig launchd agent",
316
+ recovery: "Sign in to the macOS graphical user session and ensure launchd user agents are available, then retry.",
317
+ });
318
+ }
319
+ }
320
+ });
321
+ },
322
+ };
323
+ const scheduler = options.schedulerBackend ?? nativeScheduler;
324
+ return MachineState.of({
325
+ normalizePath: (input) => normalizedPath(input, home),
326
+ userDirectories: () => Effect.succeed({
327
+ home: macosPath(home),
328
+ config: macosPath(environmentValue(environment, "XDG_CONFIG_HOME")
329
+ ?? join(home, "Library", "Application Support")),
330
+ data: macosPath(environmentValue(environment, "XDG_DATA_HOME")
331
+ ?? join(home, "Library", "Application Support")),
332
+ cache: macosPath(environmentValue(environment, "XDG_CACHE_HOME")
333
+ ?? join(home, "Library", "Caches")),
334
+ }),
335
+ ensureDirectory: (input) => requireMacosPath(input.path).pipe(Effect.flatMap((path) => machine.ensureDirectory({ ...input, path }))),
336
+ atomicWrite: (input) => requireMacosPath(input.path).pipe(Effect.flatMap((path) => machine.atomicWrite({ ...input, path }))),
337
+ readFile: (input) => requireMacosPath(input.path).pipe(Effect.flatMap((path) => machine.readFile({ ...input, path }))),
338
+ removeFile: (input) => requireMacosPath(input.path).pipe(Effect.flatMap((path) => machine.removeFile({ ...input, path }))),
339
+ removeEmptyDirectory: (input) => requireMacosPath(input.path).pipe(Effect.flatMap((path) => machine.removeEmptyDirectory({ ...input, path }))),
340
+ validatePathWithinRoot: (input) => Effect.all({
341
+ root: requireMacosPath(input.root),
342
+ path: requireMacosPath(input.path),
343
+ }).pipe(Effect.flatMap(machine.validatePathWithinRoot)),
344
+ mutateWithinRoot: (input) => Effect.all({
345
+ root: requireMacosPath(input.root),
346
+ path: requireMacosPath(input.path),
347
+ target: input.mutation.kind === "symlink"
348
+ ? requireMacosPath(input.mutation.target)
349
+ : Effect.succeed(undefined),
350
+ }).pipe(Effect.flatMap(({ root, path, target }) => machine.mutateWithinRoot({
351
+ root,
352
+ path,
353
+ mutation: input.mutation.kind === "symlink"
354
+ ? { ...input.mutation, target: target }
355
+ : input.mutation,
356
+ }))),
357
+ replaceSymlink: (input) => Effect.all({
358
+ path: requireMacosPath(input.path),
359
+ target: requireMacosPath(input.target),
360
+ }).pipe(Effect.flatMap(machine.replaceSymlink)),
361
+ readSymlink: (path) => requireMacosPath(path).pipe(Effect.flatMap(machine.readSymlink), Effect.map((target) => macosPath(target.absolute))),
362
+ inspectPath: (path) => requireMacosPath(path).pipe(Effect.flatMap(machine.inspectPath)),
363
+ setPermissions: (input) => requireMacosPath(input.path).pipe(Effect.flatMap((path) => machine.setPermissions({ ...input, path }))),
364
+ permissions: (path) => requireMacosPath(path).pipe(Effect.flatMap(machine.permissions)),
365
+ findExecutable: (query) => {
366
+ const searchPath = query.searchPath?.map(linuxPath);
367
+ return machine.findExecutable({ ...query, searchPath }).pipe(Effect.map((found) => ({ ...found, path: macosPath(found.path.absolute) })));
368
+ },
369
+ runProcess: (invocation) => Effect.all({
370
+ executable: requireMacosPath(invocation.executable),
371
+ workingDirectory: invocation.workingDirectory === undefined
372
+ ? Effect.succeed(undefined)
373
+ : requireMacosPath(invocation.workingDirectory),
374
+ }).pipe(Effect.flatMap(({ executable, workingDirectory }) => machine.runProcess({ ...invocation, executable, workingDirectory }))),
375
+ digestFile: (input) => requireMacosPath(input.path).pipe(Effect.flatMap((path) => machine.digestFile({ ...input, path }))),
376
+ credentialCapability: () => {
377
+ if (policy.kind === "local-file") {
378
+ return Effect.succeed({
379
+ kind: "local-file",
380
+ path: macosPath(resolve(policy.path)),
381
+ });
382
+ }
383
+ return secureStoreAvailable.pipe(Effect.map((available) => available
384
+ ? { kind: "secure-noninteractive", provider: "keychain" }
385
+ : {
386
+ kind: "unavailable",
387
+ recovery: "Run on macOS with an unlocked login Keychain, or explicitly select the local-file credential policy.",
388
+ }));
389
+ },
390
+ storeCredential: (input) => {
391
+ if (policy.kind === "local-file")
392
+ return machine.storeCredential(input);
393
+ if (input.name.trim().length === 0) {
394
+ return Effect.fail(new CredentialStorageError({
395
+ operation: "store credential",
396
+ reference: "keychain",
397
+ message: "credential name must not be empty",
398
+ }));
399
+ }
400
+ const key = createHash("sha256").update(input.name).digest("hex");
401
+ return requireSecurity.pipe(Effect.flatMap(() => runSecurity([
402
+ "add-generic-password",
403
+ "-U",
404
+ "-a",
405
+ "canonfig",
406
+ "-s",
407
+ `dev.canonfig.${key}`,
408
+ "-w",
409
+ Redacted.value(input.value),
410
+ ])), Effect.flatMap((result) => result.exitCode === 0
411
+ ? Effect.succeed(decode(CredentialReference)(`keychain:${key}`))
412
+ : Effect.fail(new HumanActionRequiredError({
413
+ action: "unlock macOS Keychain",
414
+ recovery: "Unlock the login Keychain for this user session, then retry.",
415
+ }))));
416
+ },
417
+ loadCredential: (input) => {
418
+ if (policy.kind === "local-file")
419
+ return machine.loadCredential(input);
420
+ return Effect.gen(function* () {
421
+ const key = yield* keychainKey(input.reference);
422
+ yield* requireSecurity;
423
+ const result = yield* runSecurity([
424
+ "find-generic-password",
425
+ "-a",
426
+ "canonfig",
427
+ "-s",
428
+ `dev.canonfig.${key}`,
429
+ "-w",
430
+ ]);
431
+ if (result.exitCode !== 0) {
432
+ return yield* new HumanActionRequiredError({
433
+ action: "provide macOS Keychain credential",
434
+ recovery: "Store the required credential in the unlocked login Keychain, then retry.",
435
+ });
436
+ }
437
+ return Redacted.make(Buffer.from(result.standardOutput).toString("utf8").replace(/\n$/u, ""));
438
+ });
439
+ },
440
+ removeCredential: (reference) => {
441
+ if (policy.kind === "local-file")
442
+ return machine.removeCredential(reference);
443
+ return Effect.gen(function* () {
444
+ const key = yield* keychainKey(reference);
445
+ yield* requireSecurity;
446
+ const result = yield* runSecurity([
447
+ "delete-generic-password",
448
+ "-a",
449
+ "canonfig",
450
+ "-s",
451
+ `dev.canonfig.${key}`,
452
+ ]);
453
+ if (result.exitCode !== 0) {
454
+ return yield* new CredentialStorageError({
455
+ operation: "remove credential",
456
+ reference: String(reference),
457
+ message: "macOS Keychain did not remove the credential",
458
+ });
459
+ }
460
+ });
461
+ },
462
+ renderSchedulerJob: renderLaunchdJob,
463
+ inspectSchedulerJob: scheduler.inspect,
464
+ snapshotSchedulerJob: scheduler.snapshot,
465
+ installSchedulerJob: scheduler.install,
466
+ removeSchedulerJob: scheduler.remove,
467
+ restoreSchedulerJob: scheduler.restore,
468
+ });
469
+ }).pipe(Effect.provide(base)));
470
+ };