@huynle/hub 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/config.ts +12 -7
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
- package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
- package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/index-DVSkeAvn.css +0 -1
- package/web/dist/assets/index-DifgVuKS.js +0 -34
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
// credential, refuse a backup that does not match what it backed up;
|
|
21
21
|
// - resume after a crash: continue the stored plan from the interrupted step, when the host still agrees.
|
|
22
22
|
//
|
|
23
|
+
// On win32 (Phase 2 contract "Install primitives on win32"): a file or link that may be in use (hubctl.exe while
|
|
24
|
+
// OpenCode runs `hubctl mcp`) cannot be renamed over, so it is replaced by rename-aside: the old entry is moved to
|
|
25
|
+
// `<name>.old-<txID>-<seq>` in the same directory, the staged one moved in, and the aside deleted when possible (else
|
|
26
|
+
// a later apply or recover sweeps it). A crash between the two renames is healed by renaming the aside back before
|
|
27
|
+
// recovery or resume looks at the target, and a rollback renames the aside back rather than copying the backup.
|
|
28
|
+
// Journal entries record no mode there (not applicable; comparisons skip it).
|
|
29
|
+
// A path a rollback cannot move or delete because a program has it open (EBUSY; on win32 also EPERM/EACCES: a
|
|
30
|
+
// running exe, or the directory holding one) never stops the rollback: every other entry is undone, the transaction
|
|
31
|
+
// stays unfinished (`failed`, recovery required) with a message naming each busy path and what to do, and
|
|
32
|
+
// `hubctl setup recover` finishes it (including the version directory moved aside, `.<name>.hub-<txID>-<seq>.tmp`)
|
|
33
|
+
// once the program has exited.
|
|
34
|
+
//
|
|
23
35
|
// The engine performs only the TxFs operations; it never spawns processes or runs commands.
|
|
24
36
|
import { randomBytes } from "node:crypto"
|
|
25
37
|
import {
|
|
@@ -32,6 +44,7 @@ import {
|
|
|
32
44
|
revalidatePreview,
|
|
33
45
|
SETUP_PREVIEW_SCHEMA,
|
|
34
46
|
snapshotDigest,
|
|
47
|
+
toNativePath,
|
|
35
48
|
validateSetupPlan,
|
|
36
49
|
type Action,
|
|
37
50
|
type ApplyLock,
|
|
@@ -52,7 +65,7 @@ import {
|
|
|
52
65
|
type TransactionJournal,
|
|
53
66
|
} from "../contracts/index.ts"
|
|
54
67
|
import type { ActionExecutor, Change, ExecutorMap, HostInfo, StepContext, StepPlan, TreeFile } from "./executor.ts"
|
|
55
|
-
import { nodeFs, type TxFs } from "./fs.ts"
|
|
68
|
+
import { createNodeFs, nodeFs, type TxFs } from "./fs.ts"
|
|
56
69
|
import { breakStaleLock, releaseLock, renewLock, tryAcquireLock, type LockIO } from "./lock.ts"
|
|
57
70
|
import { resolveTarget, rootDir, type ResolveOptions } from "./resolve.ts"
|
|
58
71
|
import { errnoOf, fromResult, TxError, txFail, txOk, type TxFailure, type TxResult } from "./result.ts"
|
|
@@ -72,6 +85,8 @@ export interface ApplierConfig {
|
|
|
72
85
|
readonly host: HostInfo
|
|
73
86
|
readonly executors: ExecutorMap
|
|
74
87
|
readonly fs?: TxFs
|
|
88
|
+
/** The platform (default process.platform): "win32" switches links, replacement and modes (see above). */
|
|
89
|
+
readonly platform?: string
|
|
75
90
|
readonly now?: () => number
|
|
76
91
|
readonly pid?: number
|
|
77
92
|
/** Whether a process on this host is alive (default: signal 0). */
|
|
@@ -209,8 +224,44 @@ function failureOf(error: unknown): TxFailure {
|
|
|
209
224
|
return txFail("internal", code ? `filesystem error ${code}: ${(error as Error).message}` : `unexpected error: ${error instanceof Error ? error.message : String(error)}`)
|
|
210
225
|
}
|
|
211
226
|
|
|
227
|
+
/** A path a rollback could not move or delete because a program has it open (Windows: a running exe). */
|
|
228
|
+
interface Busy {
|
|
229
|
+
readonly path: string
|
|
230
|
+
readonly code: string
|
|
231
|
+
readonly directory: boolean
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether `error` is a path in use: EBUSY anywhere; on win32 also EPERM/EACCES, which is what deleting or renaming a
|
|
236
|
+
* running exe (or a directory holding one) fails with. The path is the error's own, else `fallback`.
|
|
237
|
+
*/
|
|
238
|
+
function busyOf(error: unknown, fallback: string | undefined, win: boolean): Omit<Busy, "directory"> | undefined {
|
|
239
|
+
const code = errnoOf(error)
|
|
240
|
+
if (!(code === "EBUSY" || (win && (code === "EPERM" || code === "EACCES")))) return undefined
|
|
241
|
+
const own = (error as { path?: unknown }).path
|
|
242
|
+
const path = typeof own === "string" && own ? own.replace(/\\/g, "/") : fallback
|
|
243
|
+
return path ? { path, code } : undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Names every busy path and what to do; the native form on Windows. */
|
|
247
|
+
function busyMessage(txID: string, busy: readonly Busy[], platform: string): string {
|
|
248
|
+
const native = (p: string) => toNativePath(p, platform)
|
|
249
|
+
// Where the program runs from, as the user knows it: a tree a rollback moved aside (`.<name>.hub-<txID>-<seq>.tmp`)
|
|
250
|
+
// under its own name.
|
|
251
|
+
const known = (p: string) => p.replace(/\/\.([^/]+)\.hub-tx_[A-Za-z0-9_]+-\d+\.tmp(?=\/|$)/g, "/$1")
|
|
252
|
+
const dirs = [...new Set(busy.map((b) => native(known(b.directory ? b.path : dirname(b.path)))))]
|
|
253
|
+
return `rollback of ${txID} is not finished: ${busy.map((b) => `${native(b.path)} is in use (${b.code})`).join("; ")}; close OpenCode or any program running from ${dirs.join(", ")}, then run \`hubctl setup recover\``
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Where rename-aside moves the old entry of `target` (forward), or the entry hub wrote (a rollback's undo). */
|
|
257
|
+
const asidePath = (target: string, txID: string, seq: number, undo = false) => `${target}.old-${txID}-${seq}${undo ? "-undo" : ""}`
|
|
258
|
+
|
|
212
259
|
export function createApplier(config: ApplierConfig): Applier {
|
|
213
|
-
const
|
|
260
|
+
const platform = config.platform ?? process.platform
|
|
261
|
+
const win = platform === "win32"
|
|
262
|
+
const fs = config.fs ?? (config.platform === undefined ? nodeFs : createNodeFs(platform))
|
|
263
|
+
/** A mode for the journal: none on win32. */
|
|
264
|
+
const modeIn = (mode: number): { mode?: number } => (win ? {} : { mode })
|
|
214
265
|
const now = config.now ?? Date.now
|
|
215
266
|
const pid = config.pid ?? process.pid
|
|
216
267
|
const isAlive = config.isAlive ?? defaultIsAlive
|
|
@@ -218,6 +269,28 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
218
269
|
const hooks = config.hooks ?? {}
|
|
219
270
|
const context: ExecutionContext = config.context ?? "host"
|
|
220
271
|
|
|
272
|
+
/** Removes `path` if it is there; false when it is still in use (a running executable on Windows). */
|
|
273
|
+
const discard = (path: string): boolean => {
|
|
274
|
+
try {
|
|
275
|
+
if (fs.lstat(path)) fs.unlink(path)
|
|
276
|
+
return true
|
|
277
|
+
} catch {
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** win32: deletes what rename-aside left of `journal`'s steps (asides that were still in use then). */
|
|
283
|
+
const sweepAsides = (journal: TransactionJournal): void => {
|
|
284
|
+
if (!win) return
|
|
285
|
+
for (const e of journal.entries) {
|
|
286
|
+
if (!e.path) continue
|
|
287
|
+
const r = resolveTarget(e.path, config.roots, fs, { context, ...(e.class === "link" ? { finalMayBeSymlink: true } : {}) })
|
|
288
|
+
if (!r.ok) continue
|
|
289
|
+
discard(asidePath(r.value, journal.txID, e.seq))
|
|
290
|
+
discard(asidePath(r.value, journal.txID, e.seq, true))
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
221
294
|
const lockIO = (store: TxStore): LockIO => ({ fs, path: store.lockPath, hostFingerprint: config.host.fingerprint, pid, now, isAlive })
|
|
222
295
|
|
|
223
296
|
/** Interrupted transactions of this host. Another host's (a shared home) are reported, never touched here. */
|
|
@@ -262,6 +335,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
262
335
|
const lock = { current: attempt.lock }
|
|
263
336
|
try {
|
|
264
337
|
store.sweep()
|
|
338
|
+
if (win) for (const j of store.journals()) if (FINISHED.includes(j.state) && j.hostFingerprint === config.host.fingerprint) sweepAsides(j)
|
|
265
339
|
return await body(store, lock)
|
|
266
340
|
} catch (error) {
|
|
267
341
|
return failureOf(error)
|
|
@@ -489,6 +563,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
489
563
|
context,
|
|
490
564
|
host: config.host,
|
|
491
565
|
now,
|
|
566
|
+
platform,
|
|
492
567
|
target,
|
|
493
568
|
current,
|
|
494
569
|
original: this.original(step.path, current),
|
|
@@ -524,7 +599,10 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
524
599
|
break
|
|
525
600
|
}
|
|
526
601
|
}
|
|
527
|
-
if (!failure)
|
|
602
|
+
if (!failure) {
|
|
603
|
+
this.persist({ state: "committed" })
|
|
604
|
+
sweepAsides(this.journal)
|
|
605
|
+
}
|
|
528
606
|
} catch (error) {
|
|
529
607
|
failure = failureOf(error)
|
|
530
608
|
}
|
|
@@ -622,7 +700,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
622
700
|
const info = fs.lstat(abs)
|
|
623
701
|
if (info?.kind === "directory") return
|
|
624
702
|
if (info) throw new TxError("conflict", `${abs} is not a directory`)
|
|
625
|
-
const entry = await this.append({ actionID: action.id, kind: action.kind, class: "directory", path: ref, before: { exists: false }, after: { exists: true,
|
|
703
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: "directory", path: ref, before: { exists: false }, after: { exists: true, ...modeIn(PARENT_MODE) }, status: "pending" })
|
|
626
704
|
const again = this.resolve(ref)
|
|
627
705
|
if (!again.ok) throw new TxError(again.code, `${action.id}: ${again.message}`)
|
|
628
706
|
fs.mkdir(abs, PARENT_MODE)
|
|
@@ -710,7 +788,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
710
788
|
case "directory": {
|
|
711
789
|
if (current.exists) return kind === "directory" ? this.skip() : conflict("exists and is not a directory")
|
|
712
790
|
await this.parents(action, path!)
|
|
713
|
-
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: { exists: true,
|
|
791
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: { exists: true, ...modeIn(change.mode) }, status: "pending" })
|
|
714
792
|
this.recheck(action, step)
|
|
715
793
|
fs.mkdir(target!, change.mode)
|
|
716
794
|
fs.chmod(target!, change.mode)
|
|
@@ -721,7 +799,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
721
799
|
case "symlink": {
|
|
722
800
|
const desired: FileState =
|
|
723
801
|
change.type === "file"
|
|
724
|
-
? { exists: true, sha256: "bytes" in change ? sha256Of(change.bytes) : change.staged.sha256,
|
|
802
|
+
? { exists: true, sha256: "bytes" in change ? sha256Of(change.bytes) : change.staged.sha256, ...modeIn(change.mode) }
|
|
725
803
|
: { exists: true, symlink: true, sha256: sha256Of(change.target) }
|
|
726
804
|
if (current.exists) {
|
|
727
805
|
if (sameState(current, desired) && (change.type === "symlink" || kind === "file")) return this.skip()
|
|
@@ -737,7 +815,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
737
815
|
if (change.type === "symlink") fs.symlink(change.target, staged)
|
|
738
816
|
else if ("bytes" in change) fs.writeExclusive(staged, change.bytes, change.mode)
|
|
739
817
|
else this.place(change.staged, staged, change.mode, ctx.staging)
|
|
740
|
-
|
|
818
|
+
this.replace(staged, target!, asidePath(target!, this.txID, entry.seq))
|
|
741
819
|
fs.fsyncDir(dirname(target!))
|
|
742
820
|
const after = fileState(fs, target!)
|
|
743
821
|
if (!sameState(after, desired)) throw new TxError("conflict", `${action.id}: ${target} changed while it was being written`)
|
|
@@ -745,7 +823,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
745
823
|
return txOk("applied")
|
|
746
824
|
}
|
|
747
825
|
case "tree": {
|
|
748
|
-
const desired: FileState = { exists: true, sha256: treeDigestOfFiles(change.files, change.mode
|
|
826
|
+
const desired: FileState = { exists: true, sha256: treeDigestOfFiles(change.files, change.mode, { modes: !win }), ...modeIn(change.mode) }
|
|
749
827
|
if (current.exists) return sameState(current, desired) ? this.skip() : conflict("already exists with other content (install roots are never overwritten)")
|
|
750
828
|
await this.parents(action, path!)
|
|
751
829
|
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: desired, status: "pending" })
|
|
@@ -762,6 +840,43 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
762
840
|
}
|
|
763
841
|
}
|
|
764
842
|
|
|
843
|
+
/**
|
|
844
|
+
* Moves `staged` over `target`. POSIX: one atomic rename. win32: rename-aside when something is there (a rename
|
|
845
|
+
* cannot replace a directory link or a file in use): the old entry to `aside`, the staged one in (on failure the
|
|
846
|
+
* old one is moved back), then the aside deleted if it is no longer in use (else swept later).
|
|
847
|
+
*/
|
|
848
|
+
private replace(staged: string, target: string, aside: string): void {
|
|
849
|
+
if (!win || !fs.lstat(target)) {
|
|
850
|
+
fs.rename(staged, target)
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
discard(aside)
|
|
854
|
+
fs.rename(target, aside)
|
|
855
|
+
try {
|
|
856
|
+
fs.rename(staged, target)
|
|
857
|
+
} catch (error) {
|
|
858
|
+
try {
|
|
859
|
+
fs.rename(aside, target)
|
|
860
|
+
} catch {}
|
|
861
|
+
throw error
|
|
862
|
+
}
|
|
863
|
+
discard(aside)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* win32: a crash between the two renames of a rename-aside leaves `target` missing and its aside in place; this
|
|
868
|
+
* renames the aside back (a rollback's, else the forward step's) so recovery sees the target as it was.
|
|
869
|
+
*/
|
|
870
|
+
private heal(target: string, seq: number): void {
|
|
871
|
+
if (!win || fs.lstat(target)) return
|
|
872
|
+
for (const aside of [asidePath(target, this.txID, seq, true), asidePath(target, this.txID, seq)]) {
|
|
873
|
+
if (fs.lstat(aside)) {
|
|
874
|
+
fs.rename(aside, target)
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
765
880
|
private stageTree(dir: string, files: readonly TreeFile[], mode: number, staging: StagingArea): void {
|
|
766
881
|
fs.mkdir(dir, mode)
|
|
767
882
|
fs.chmod(dir, mode)
|
|
@@ -809,6 +924,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
809
924
|
}
|
|
810
925
|
const target = this.resolve(entry.path, this.resolveOptions(entry))
|
|
811
926
|
if (!target.ok) return txFail("recovery_required", `${entry.actionID}: ${target.message}; roll back instead`)
|
|
927
|
+
this.heal(target.value, entry.seq)
|
|
812
928
|
const current = fileState(fs, target.value, { tree: entry.class === "harness" })
|
|
813
929
|
if (entry.after && sameState(current, entry.after)) {
|
|
814
930
|
this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? { ...e, status: "done" } : e)) })
|
|
@@ -835,18 +951,40 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
835
951
|
|
|
836
952
|
async rollbackAll(): Promise<TxOutcome> {
|
|
837
953
|
const decided: RecoveryStep[] = []
|
|
954
|
+
const busy: Busy[] = []
|
|
955
|
+
/** Runs one entry's undo; a path in use (a running program) is noted and the rollback goes on. */
|
|
956
|
+
const tolerant = async (entry: JournalEntry, work: () => Promise<void> | void) => {
|
|
957
|
+
try {
|
|
958
|
+
await work()
|
|
959
|
+
} catch (error) {
|
|
960
|
+
const fallback = entry.path ? this.resolve(entry.path, this.resolveOptions(entry)) : undefined
|
|
961
|
+
const found = busyOf(error, fallback?.ok ? fallback.value : undefined, win)
|
|
962
|
+
if (!found) throw error
|
|
963
|
+
if (!busy.some((b) => b.path === found.path)) busy.push({ ...found, directory: fs.lstat(found.path)?.kind === "directory" })
|
|
964
|
+
}
|
|
965
|
+
}
|
|
838
966
|
try {
|
|
839
967
|
if (this.journal.state !== "rolling-back") this.persist({ state: "rolling-back" })
|
|
840
968
|
for (const entry of [...this.journal.entries].reverse()) {
|
|
841
969
|
if (entry.status === "undone" || entry.status === "skipped") {
|
|
842
970
|
// An interrupted earlier rollback may have left this entry's staging (or a tree moved aside) behind.
|
|
843
|
-
|
|
844
|
-
|
|
971
|
+
await tolerant(entry, () => {
|
|
972
|
+
const r = entry.path ? this.resolve(entry.path, this.resolveOptions(entry)) : undefined
|
|
973
|
+
if (r?.ok) this.removeStaging(r.value, entry.seq)
|
|
974
|
+
})
|
|
845
975
|
continue
|
|
846
976
|
}
|
|
847
|
-
|
|
977
|
+
await tolerant(entry, async () => {
|
|
978
|
+
decided.push({ seq: entry.seq, actionID: entry.actionID, decision: await this.undo(entry) })
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
if (busy.length > 0) {
|
|
982
|
+
// Everything else is undone; the transaction stays unfinished until the program has exited.
|
|
983
|
+
this.persist({ state: "failed" })
|
|
984
|
+
return { ...txFail("recovery_required", busyMessage(this.txID, busy, platform)), report: this.report() }
|
|
848
985
|
}
|
|
849
986
|
this.persist({ state: "rolled-back" })
|
|
987
|
+
sweepAsides(this.journal)
|
|
850
988
|
this.cleanup()
|
|
851
989
|
await hooks.onRecover?.(copy(this.journal), copy(decided))
|
|
852
990
|
return { ok: true, report: this.report() }
|
|
@@ -890,6 +1028,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
890
1028
|
}
|
|
891
1029
|
const target = resolved.value
|
|
892
1030
|
this.removeStaging(target, entry.seq)
|
|
1031
|
+
this.heal(target, entry.seq)
|
|
893
1032
|
const current = fileState(fs, target, { tree: entry.class === "harness" })
|
|
894
1033
|
if (sameState(current, entry.before)) {
|
|
895
1034
|
await this.mark(entry, entry.after ? "undone" : "skipped")
|
|
@@ -906,6 +1045,14 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
906
1045
|
await this.conflict(entry, target, "changed since hub wrote it; kept as it is", current)
|
|
907
1046
|
break
|
|
908
1047
|
case "restore-backup": {
|
|
1048
|
+
// win32: the old entry rename-aside moved away is still there when it was in use: rename it back (the very
|
|
1049
|
+
// file that may be running), rather than copying the backup over it.
|
|
1050
|
+
const aside = asidePath(target, this.txID, entry.seq)
|
|
1051
|
+
if (win && fs.lstat(aside) && sameState(fileState(fs, aside), entry.before)) {
|
|
1052
|
+
this.replace(aside, target, asidePath(target, this.txID, entry.seq, true))
|
|
1053
|
+
await this.mark(entry, "undone")
|
|
1054
|
+
break
|
|
1055
|
+
}
|
|
909
1056
|
// A symlink's backup is its target text; a file's is read and copied in chunks, never whole.
|
|
910
1057
|
const link = entry.backup && entry.before.symlink ? this.store.readBackup(this.txID, entry.backup.id) : undefined
|
|
911
1058
|
const file = entry.backup && !entry.before.symlink ? this.store.backupFile(this.txID, entry.backup.id) : undefined
|
|
@@ -921,7 +1068,7 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
921
1068
|
await this.conflict(entry, target, "its backup changed while it was restored; left as hub wrote it", current)
|
|
922
1069
|
return "keep-user-edit"
|
|
923
1070
|
}
|
|
924
|
-
|
|
1071
|
+
this.replace(staged, target, asidePath(target, this.txID, entry.seq, true))
|
|
925
1072
|
fs.fsyncDir(dirname(target))
|
|
926
1073
|
await this.mark(entry, "undone")
|
|
927
1074
|
break
|
|
@@ -944,6 +1091,13 @@ export function createApplier(config: ApplierConfig): Applier {
|
|
|
944
1091
|
return "keep-user-edit"
|
|
945
1092
|
}
|
|
946
1093
|
await this.mark(entry, "undone")
|
|
1094
|
+
} else if (win && kind === "file") {
|
|
1095
|
+
// A file hub created may be running now (hubctl.exe): move it aside, then delete it if it is not.
|
|
1096
|
+
const aside = asidePath(target, this.txID, entry.seq, true)
|
|
1097
|
+
discard(aside)
|
|
1098
|
+
fs.rename(target, aside)
|
|
1099
|
+
await this.mark(entry, "undone")
|
|
1100
|
+
discard(aside)
|
|
947
1101
|
} else {
|
|
948
1102
|
fs.unlink(target)
|
|
949
1103
|
await this.mark(entry, "undone")
|
|
@@ -72,6 +72,11 @@ export interface StepContext {
|
|
|
72
72
|
readonly context: ExecutionContext
|
|
73
73
|
readonly host: HostInfo
|
|
74
74
|
readonly now: () => number
|
|
75
|
+
/**
|
|
76
|
+
* The platform apply runs on (process.platform unless injected). On "win32" links differ: link-executable writes a
|
|
77
|
+
* cmd shim, directory links are junctions with an absolute target (Phase 2 contract).
|
|
78
|
+
*/
|
|
79
|
+
readonly platform?: string
|
|
75
80
|
/** The resolved, symlink-checked absolute path of the step's target (when it has one). */
|
|
76
81
|
readonly target?: string
|
|
77
82
|
/** The target's state now. */
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
// Writes are only ever "create a new file exclusively" (O_CREAT|O_EXCL|O_NOFOLLOW, then fsync); replacing a file is
|
|
6
6
|
// always stage-then-rename, so a reader (or a crash) sees the old file or the new one, never a torn mix. Large files
|
|
7
7
|
// (a harness binary, hubctl itself) are written and read in chunks (createExclusive, readRange), never whole.
|
|
8
|
+
//
|
|
9
|
+
// On Windows (createNodeFs("win32"), Phase 2 contract): O_NOFOLLOW does not exist, so every open that must not
|
|
10
|
+
// follow a link is preceded by an explicit lstat that refuses one (lstat reports junctions and symlinks alike as
|
|
11
|
+
// links); there are no POSIX modes (lstat reports `mode: null`, chmod and fchmod are no-ops, files are created
|
|
12
|
+
// writable); a directory cannot be opened for fsync (fsyncDir is a no-op); and a directory link is an NTFS junction
|
|
13
|
+
// with an absolute target (no admin or Developer Mode needed), read back in canonical form (C:/…).
|
|
8
14
|
import {
|
|
9
15
|
chmodSync,
|
|
10
16
|
closeSync,
|
|
@@ -26,11 +32,12 @@ import {
|
|
|
26
32
|
writeSync,
|
|
27
33
|
} from "node:fs"
|
|
28
34
|
import type { EntryKind } from "../contracts/index.ts"
|
|
35
|
+
import { canonicalWinLink, normalizeAbs } from "../discovery/util.ts"
|
|
29
36
|
|
|
30
37
|
export interface EntryInfo {
|
|
31
38
|
readonly kind: EntryKind
|
|
32
|
-
/** Permission bits (0..0o7777). */
|
|
33
|
-
readonly mode: number
|
|
39
|
+
/** Permission bits (0..0o7777); null where the platform has none (win32). */
|
|
40
|
+
readonly mode: number | null
|
|
34
41
|
readonly size: number
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -87,7 +94,8 @@ function kindOf(mode: number): EntryKind {
|
|
|
87
94
|
|
|
88
95
|
const errno = (error: unknown) => (error as NodeJS.ErrnoException | undefined)?.code
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
/** The POSIX filesystem (macOS, Linux). */
|
|
98
|
+
const posixFs: TxFs = {
|
|
91
99
|
lstat(path) {
|
|
92
100
|
try {
|
|
93
101
|
const s = lstatSync(path)
|
|
@@ -192,3 +200,134 @@ export const nodeFs: TxFs = {
|
|
|
192
200
|
} catch {}
|
|
193
201
|
},
|
|
194
202
|
}
|
|
203
|
+
|
|
204
|
+
export { canonicalWinLink }
|
|
205
|
+
|
|
206
|
+
const linkError = (path: string) => Object.assign(new Error(`ELOOP: ${path} is a link; refusing to open it`), { code: "ELOOP", path })
|
|
207
|
+
|
|
208
|
+
/** O_NOFOLLOW's guarantee where it does not exist: refuse a link (symlink or junction) at `path` right before an open. */
|
|
209
|
+
function refuseLink(path: string): void {
|
|
210
|
+
let link = false
|
|
211
|
+
try {
|
|
212
|
+
link = lstatSync(path).isSymbolicLink()
|
|
213
|
+
} catch {}
|
|
214
|
+
if (link) throw linkError(path)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* POSIX modes do not exist on Windows; Node maps a mode without 0o200 to a read-only file, which is never wanted.
|
|
219
|
+
* Opens use string flags ("wx", "r"): Bun on Windows does not honour numeric O_CREAT/O_EXCL from fs.constants
|
|
220
|
+
* (seen on Windows 11, Bun 1.3.5: O_CREAT|O_EXCL|O_WRONLY on a missing file fails with ENOENT, and on an existing
|
|
221
|
+
* junction opens it instead of failing).
|
|
222
|
+
*/
|
|
223
|
+
const WIN_CREATE_MODE = 0o666
|
|
224
|
+
|
|
225
|
+
const parentOf = (p: string) => {
|
|
226
|
+
const at = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"))
|
|
227
|
+
return at <= 0 ? p : p.slice(0, at)
|
|
228
|
+
}
|
|
229
|
+
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
|
|
230
|
+
|
|
231
|
+
const winFs: TxFs = {
|
|
232
|
+
...posixFs,
|
|
233
|
+
lstat(path) {
|
|
234
|
+
const info = posixFs.lstat(path)
|
|
235
|
+
return info ? { ...info, mode: null } : undefined
|
|
236
|
+
},
|
|
237
|
+
readRange(path, offset, length) {
|
|
238
|
+
refuseLink(path)
|
|
239
|
+
const fd = openSync(path, "r")
|
|
240
|
+
try {
|
|
241
|
+
const out = new Uint8Array(length)
|
|
242
|
+
let got = 0
|
|
243
|
+
while (got < length) {
|
|
244
|
+
const n = readSync(fd, out, got, length - got, offset + got)
|
|
245
|
+
if (n === 0) break
|
|
246
|
+
got += n
|
|
247
|
+
}
|
|
248
|
+
return got === length ? out : out.subarray(0, got)
|
|
249
|
+
} finally {
|
|
250
|
+
closeSync(fd)
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
readlink: (path) => canonicalWinLink(readlinkSync(path)),
|
|
254
|
+
writeExclusive(path, data, _mode) {
|
|
255
|
+
refuseLink(path)
|
|
256
|
+
const fd = openSync(path, "wx", WIN_CREATE_MODE)
|
|
257
|
+
let ok = false
|
|
258
|
+
try {
|
|
259
|
+
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : data
|
|
260
|
+
let offset = 0
|
|
261
|
+
while (offset < bytes.byteLength) offset += writeSync(fd, bytes, offset, bytes.byteLength - offset)
|
|
262
|
+
fsyncSync(fd)
|
|
263
|
+
ok = true
|
|
264
|
+
} finally {
|
|
265
|
+
closeSync(fd)
|
|
266
|
+
if (!ok) {
|
|
267
|
+
try {
|
|
268
|
+
unlinkSync(path)
|
|
269
|
+
} catch {}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
createExclusive(path, _mode) {
|
|
274
|
+
refuseLink(path)
|
|
275
|
+
const fd = openSync(path, "wx", WIN_CREATE_MODE)
|
|
276
|
+
let open = true
|
|
277
|
+
const finish = (complete: boolean) => {
|
|
278
|
+
if (!open) return
|
|
279
|
+
open = false
|
|
280
|
+
try {
|
|
281
|
+
if (complete) fsyncSync(fd)
|
|
282
|
+
} finally {
|
|
283
|
+
closeSync(fd)
|
|
284
|
+
if (!complete) {
|
|
285
|
+
try {
|
|
286
|
+
unlinkSync(path)
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
write(chunk) {
|
|
293
|
+
if (!open) throw new Error(`${path} is already closed`)
|
|
294
|
+
let offset = 0
|
|
295
|
+
while (offset < chunk.byteLength) offset += writeSync(fd, chunk, offset, chunk.byteLength - offset)
|
|
296
|
+
},
|
|
297
|
+
close() {
|
|
298
|
+
try {
|
|
299
|
+
finish(true)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
try {
|
|
302
|
+
unlinkSync(path)
|
|
303
|
+
} catch {}
|
|
304
|
+
throw error
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
abort: () => finish(false),
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
unlink(path) {
|
|
311
|
+
try {
|
|
312
|
+
unlinkSync(path)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
// A junction is a directory link: some runtimes remove it with rmdir only (the target is never touched).
|
|
315
|
+
const code = errno(error)
|
|
316
|
+
if ((code === "EPERM" || code === "EISDIR" || code === "EACCES") && lstatSync(path).isSymbolicLink()) rmdirSync(path)
|
|
317
|
+
else throw error
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
mkdir: (path) => mkdirSync(path),
|
|
321
|
+
mkdirp: (path) => void mkdirSync(path, { recursive: true }),
|
|
322
|
+
symlink(target, path) {
|
|
323
|
+
const absolute = isAbsolute(target) ? target : `${parentOf(path)}/${target}`
|
|
324
|
+
symlinkSync(absolute, path, "junction")
|
|
325
|
+
},
|
|
326
|
+
chmod: () => {},
|
|
327
|
+
fsyncDir: () => {},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The filesystem for `platform` (default: this process's). */
|
|
331
|
+
export const createNodeFs = (platform: string = process.platform): TxFs => (platform === "win32" ? winFs : posixFs)
|
|
332
|
+
|
|
333
|
+
export const nodeFs: TxFs = createNodeFs()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The cmd shim that stands in for an executable symlink on Windows (Phase 2 contract): symlinks to executables need
|
|
2
|
+
// Developer Mode or admin there, so link-executable writes `user-bin:<name>.cmd` holding exactly
|
|
3
|
+
// `@"<native target>" %*\r\n` (ASCII). The applier journals it as a file it owns; readers that check "is this the
|
|
4
|
+
// hub link to X" (setup verify, the user-bin link rule) parse it back with parseCmdShim.
|
|
5
|
+
import { normalizeAbs } from "../discovery/util.ts"
|
|
6
|
+
import { toNativePath } from "../contracts/index.ts"
|
|
7
|
+
|
|
8
|
+
// Inside the quotes cmd still expands %VAR%; a quote would end them. Printable ASCII only.
|
|
9
|
+
const UNSAFE = /[^\x20-\x7e]|["%]/
|
|
10
|
+
|
|
11
|
+
/** `path` (canonical C:/… or native) as one double-quoted cmd word. Throws for a path cmd would not take literally. */
|
|
12
|
+
export function cmdWord(path: string): string {
|
|
13
|
+
const native = toNativePath(path, "win32")
|
|
14
|
+
if (UNSAFE.test(native)) throw new Error(`cannot write a cmd shim for ${JSON.stringify(path)}: the path must be printable ASCII without " or %`)
|
|
15
|
+
return `"${native}"`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The shim's bytes for the canonical `target` (C:/… on Windows). Throws for a target cmd would not run literally. */
|
|
19
|
+
export function cmdShim(target: string): Uint8Array {
|
|
20
|
+
return new TextEncoder().encode(`@${cmdWord(target)} %*\r\n`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SHIM = /^@"([\x20-\x7e]+)" %\*\r\n$/
|
|
24
|
+
|
|
25
|
+
/** The canonical target of a hub cmd shim, or undefined when `bytes` are anything else. */
|
|
26
|
+
export function parseCmdShim(bytes: Uint8Array): string | undefined {
|
|
27
|
+
if (bytes.byteLength > 4096) return undefined
|
|
28
|
+
const m = SHIM.exec(new TextDecoder("latin1").decode(bytes))
|
|
29
|
+
if (!m || UNSAFE.test(m[1]!)) return undefined
|
|
30
|
+
return normalizeAbs(m[1]!)
|
|
31
|
+
}
|
|
@@ -7,6 +7,9 @@ import type { TreeFile } from "./executor.ts"
|
|
|
7
7
|
import type { TxFs } from "./fs.ts"
|
|
8
8
|
import { fileDigest } from "./stream.ts"
|
|
9
9
|
|
|
10
|
+
/** A FileState's mode, omitted where the platform has none (win32: lstat reports null, comparisons skip it). */
|
|
11
|
+
const modeOf = (mode: number | null) => (mode === null ? {} : { mode })
|
|
12
|
+
|
|
10
13
|
export const sha256Of = (data: Uint8Array | string): string => `sha256:${sha256Hex(data)}`
|
|
11
14
|
|
|
12
15
|
export function fileState(fs: TxFs, path: string, options: { readonly tree?: boolean } = {}): FileState {
|
|
@@ -14,11 +17,11 @@ export function fileState(fs: TxFs, path: string, options: { readonly tree?: boo
|
|
|
14
17
|
if (!info) return { exists: false }
|
|
15
18
|
switch (info.kind) {
|
|
16
19
|
case "file":
|
|
17
|
-
return { exists: true, sha256: fileDigest(fs, path),
|
|
20
|
+
return { exists: true, sha256: fileDigest(fs, path), ...modeOf(info.mode) }
|
|
18
21
|
case "symlink":
|
|
19
22
|
return { exists: true, symlink: true, sha256: sha256Of(fs.readlink(path)) }
|
|
20
23
|
case "directory":
|
|
21
|
-
return options.tree ? { exists: true, sha256: treeDigest(fs, path),
|
|
24
|
+
return options.tree ? { exists: true, sha256: treeDigest(fs, path), ...modeOf(info.mode) } : { exists: true, ...modeOf(info.mode) }
|
|
22
25
|
default:
|
|
23
26
|
return { exists: true }
|
|
24
27
|
}
|
|
@@ -47,9 +50,11 @@ export function treeDigest(fs: TxFs, dir: string): string {
|
|
|
47
50
|
|
|
48
51
|
/**
|
|
49
52
|
* The digest treeDigest would compute for a directory holding exactly `files` (directories implied by their paths,
|
|
50
|
-
* all with `dirMode`), so the journal can record a tree's state before it is staged.
|
|
53
|
+
* all with `dirMode`), so the journal can record a tree's state before it is staged. `modes: false` where the
|
|
54
|
+
* platform has none (win32): every entry's mode is null, as treeDigest reads it there.
|
|
51
55
|
*/
|
|
52
|
-
export function treeDigestOfFiles(files: readonly TreeFile[], dirMode: number): string {
|
|
56
|
+
export function treeDigestOfFiles(files: readonly TreeFile[], dirMode: number, options: { readonly modes?: boolean } = {}): string {
|
|
57
|
+
const withModes = options.modes !== false
|
|
53
58
|
type Dir = Map<string, Dir | { readonly sha256: string; readonly mode: number }>
|
|
54
59
|
const root: Dir = new Map()
|
|
55
60
|
for (const f of files) {
|
|
@@ -71,9 +76,9 @@ export function treeDigestOfFiles(files: readonly TreeFile[], dirMode: number):
|
|
|
71
76
|
const child = dir.get(name)!
|
|
72
77
|
const childRel = rel ? `${rel}/${name}` : name
|
|
73
78
|
if (child instanceof Map) {
|
|
74
|
-
entries.push({ rel: childRel, kind: "directory", mode: dirMode })
|
|
79
|
+
entries.push({ rel: childRel, kind: "directory", mode: withModes ? dirMode : null })
|
|
75
80
|
walk(child, childRel)
|
|
76
|
-
} else entries.push({ rel: childRel, kind: "file", mode: child.mode, sha256: child.sha256 })
|
|
81
|
+
} else entries.push({ rel: childRel, kind: "file", mode: withModes ? child.mode : null, sha256: child.sha256 })
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
walk(root, "")
|
|
@@ -5,6 +5,7 @@ import { randomBytes } from "node:crypto"
|
|
|
5
5
|
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
6
6
|
import path from "node:path"
|
|
7
7
|
import type { Fetch } from "../../sdk/src/index.ts"
|
|
8
|
+
import { defaultWinAcl, isWin32, writeOwnerOnlyFile, type SecretFileOptions } from "../../../src/win-acl.ts"
|
|
8
9
|
|
|
9
10
|
export type Env = Record<string, string | undefined>
|
|
10
11
|
|
|
@@ -42,9 +43,11 @@ export function readLogin(file: string): Login | undefined {
|
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Writes the login file, owner-only (0600; its directory 0700 when created), atomically: a private temporary file
|
|
45
|
-
* next to it is renamed over it, so a reader never sees half a file and a failed write leaves the old one.
|
|
46
|
+
* next to it is renamed over it, so a reader never sees half a file and a failed write leaves the old one. On
|
|
47
|
+
* Windows the temporary file is restricted to the current user (an ACL) before the token is written (win-acl.ts).
|
|
46
48
|
*/
|
|
47
|
-
export function writeLogin(file: string, login: Login): void {
|
|
49
|
+
export function writeLogin(file: string, login: Login, options?: SecretFileOptions): void {
|
|
50
|
+
if (isWin32(options)) return writeOwnerOnlyFile(file, `${JSON.stringify({ url: login.url, token: login.token }, null, 2)}\n`, options?.acl ?? defaultWinAcl())
|
|
48
51
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
49
52
|
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`
|
|
50
53
|
try {
|
package/packages/cli/src/main.ts
CHANGED
|
@@ -38,9 +38,10 @@ const presetRun = argv[0] === "preset" && argv[1] === "run"
|
|
|
38
38
|
// `hubctl machine enroll` and `hub login` (device code) poll until the code is approved: Ctrl-C cancels them (exit 4,
|
|
39
39
|
// nothing stored). `hubctl setup apply` / `recover` pass the signal to their probes and the verification launch, so
|
|
40
40
|
// Ctrl-C fails the running step and the transaction rolls back, instead of the process dying between a write and its
|
|
41
|
-
// journal entry.
|
|
41
|
+
// journal entry. `setup verify` passes it to its probes too: they lead their own process group (process-tree.ts), so
|
|
42
|
+
// the terminal's Ctrl-C reaches them only through this signal, which ends each probe's whole tree.
|
|
42
43
|
const enroll = (argv[0] === "machine" && argv[1] === "enroll") || argv[0] === "login"
|
|
43
|
-
const applying = argv[0] === "setup" && (argv[1] === "apply" || argv[1] === "recover")
|
|
44
|
+
const applying = argv[0] === "setup" && (argv[1] === "apply" || argv[1] === "recover" || argv[1] === "verify")
|
|
44
45
|
const controller = new AbortController()
|
|
45
46
|
if (argv[0] === "watch" || argv[0] === "mcp" || nodeRun || claudeDaemon || claudeMcp || codexServe || presetRun || enroll || applying)
|
|
46
47
|
for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort())
|