@joshuafolkken/kit 1.947.0 → 1.953.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 +2 -5
- package/dist/hooks/{chunk-3FWV6CFC.js → chunk-452OKRQX.js} +308 -176
- package/dist/hooks/{chunk-GIX5SAAB.js → chunk-Z5TVZ4AP.js} +1 -1
- package/dist/hooks/codex-hook-adapter.js +2 -2
- package/dist/hooks/pretool-guard.js +2 -2
- package/dist/hooks/stop-guard.js +1 -1
- package/dist/version/index.js +7 -4
- package/package.json +1 -1
- package/scripts/git/git-worktree.ts +35 -4
- package/scripts/git/repository-lock.ts +138 -0
- package/scripts/git/stash-sweep-lock.ts +6 -117
- package/scripts/safe-chain/project-config.ts +55 -10
- package/scripts/sync/sync.ts +3 -1
- package/scripts/version/release-age.ts +11 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @joshuafolkken/kit
|
|
2
2
|
|
|
3
|
-
A development toolkit that sets up a repository for AI-assisted work with one command. `josh init` gives an AI assistant (Claude Code, Codex, Gemini, Cursor) the project's rules, and the `josh` CLI runs the checks and the Git / GitHub Issue workflow the same way in every project. You stop copying config between repositories and re-explaining conventions to the assistant.
|
|
3
|
+
A development toolkit that sets up a repository for AI-assisted work with one command. `josh init` gives an AI assistant (Claude Code, Codex, Gemini, Cursor) the project's rules, and the `josh` CLI runs the checks and the Git / GitHub Issue workflow the same way in every project. You stop copying config between repositories and re-explaining conventions to the assistant. For the problems kit exists to solve, see [why.md](./docs/why.md) (Japanese).
|
|
4
4
|
|
|
5
5
|
## What your project gets
|
|
6
6
|
|
|
@@ -34,11 +34,8 @@ It needs [Node.js](https://nodejs.org/) 22.19.0 or later with [pnpm](https://pnp
|
|
|
34
34
|
|
|
35
35
|
- [getting-started.md](./docs/getting-started.md) — first install for an `index.html` site or a non-Node project (`static`)
|
|
36
36
|
- [package.md](./docs/package.md) — add kit to a Node project (`node`)
|
|
37
|
+
- [tutorial.md](./docs/tutorial.md) — your first change with an agent, from Issue to merge
|
|
37
38
|
- [how-to.md](./docs/how-to.md) — step-by-step guides by task: update, release, run Issues, fix a failing gate
|
|
38
|
-
|
|
39
|
-
**Understand kit**
|
|
40
|
-
|
|
41
|
-
- [why.md](./docs/why.md) — the problems kit exists to solve
|
|
42
39
|
- [overview.md](./docs/overview.md) — what kit sets up and how it works
|
|
43
40
|
|
|
44
41
|
**Commands and configuration**
|
|
@@ -1906,8 +1906,8 @@ function current_owner(repository_path = process.cwd()) {
|
|
|
1906
1906
|
var repo_party = { FIRST_PARTY, THIRD_PARTY, UNKNOWN, classify: classify3, current_owner, target_owner };
|
|
1907
1907
|
|
|
1908
1908
|
// scripts/lane/lane-registry.ts
|
|
1909
|
-
import { existsSync as existsSync2, readFileSync as
|
|
1910
|
-
import
|
|
1909
|
+
import { existsSync as existsSync2, readFileSync as readFileSync4 } from "node:fs";
|
|
1910
|
+
import path3 from "node:path";
|
|
1911
1911
|
|
|
1912
1912
|
// scripts/git/git-ls-remote.ts
|
|
1913
1913
|
var LS_REMOTE_COMMAND = "ls-remote";
|
|
@@ -1918,23 +1918,323 @@ function ls_remote_branch_arguments(branch_name) {
|
|
|
1918
1918
|
return [LS_REMOTE_COMMAND, HEADS_FLAG, ORIGIN_REMOTE, `${HEADS_REF_PREFIX}${branch_name}`];
|
|
1919
1919
|
}
|
|
1920
1920
|
|
|
1921
|
+
// scripts/git/repository-lock.ts
|
|
1922
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1923
|
+
import { linkSync, renameSync, rmSync as rmSync2 } from "node:fs";
|
|
1924
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
1925
|
+
|
|
1926
|
+
// scripts/josh/process-identity.ts
|
|
1927
|
+
import { execFileSync as execFileSync2, spawnSync } from "node:child_process";
|
|
1928
|
+
import { randomUUID } from "node:crypto";
|
|
1929
|
+
import { readFileSync as readFileSync3, rmSync } from "node:fs";
|
|
1930
|
+
import { createServer } from "node:net";
|
|
1931
|
+
import path2 from "node:path";
|
|
1932
|
+
var PS_COMMAND = "/bin/ps";
|
|
1933
|
+
var FORMAT_FLAG = "-o";
|
|
1934
|
+
var START_TIME_FORMAT = "lstart=";
|
|
1935
|
+
var PID_FLAG = "-p";
|
|
1936
|
+
var PROBE_ENVIRONMENT = { TZ: "UTC", LC_ALL: "C" };
|
|
1937
|
+
var PROBE_TIMEOUT_MS = 2e3;
|
|
1938
|
+
var LIVENESS_SIGNAL = 0;
|
|
1939
|
+
var LOWEST_REAL_PID = 1;
|
|
1940
|
+
var PROC_START_FIELD_INDEX = 19;
|
|
1941
|
+
var BEACON_SCHEME = "socket:";
|
|
1942
|
+
var BEACON_SUFFIX = ".sock";
|
|
1943
|
+
var BEACON_PREFIX = `josh-process-identity-${String(process.getuid?.() ?? "")}-`;
|
|
1944
|
+
var BEACON_ID_LENGTH = 36;
|
|
1945
|
+
var BEACON_ID_PATTERN = /^[\da-f-]+$/u;
|
|
1946
|
+
var WINDOWS_PLATFORM = "win32";
|
|
1947
|
+
var SOCKET_PROBE_SOURCE = "const net=require('node:net');const socket=net.createConnection(process.argv[1]);socket.once('connect',()=>process.exit(0));socket.once('error',()=>process.exit(1));socket.setTimeout(1000,()=>process.exit(1));";
|
|
1948
|
+
var beacons = /* @__PURE__ */ new Map();
|
|
1949
|
+
var exit_cleanup = { is_registered: false };
|
|
1950
|
+
function is_live_pid(pid) {
|
|
1951
|
+
if (pid < LOWEST_REAL_PID) return false;
|
|
1952
|
+
try {
|
|
1953
|
+
process.kill(pid, LIVENESS_SIGNAL);
|
|
1954
|
+
return true;
|
|
1955
|
+
} catch {
|
|
1956
|
+
return false;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
function read_proc_start(pid) {
|
|
1960
|
+
try {
|
|
1961
|
+
const stat = readFileSync3(`/proc/${String(pid)}/stat`, "utf8");
|
|
1962
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/u);
|
|
1963
|
+
const start = fields[PROC_START_FIELD_INDEX];
|
|
1964
|
+
return start === void 0 ? void 0 : `proc:${start}`;
|
|
1965
|
+
} catch {
|
|
1966
|
+
return void 0;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
function read_ps_start(pid) {
|
|
1970
|
+
try {
|
|
1971
|
+
const printed = execFileSync2(
|
|
1972
|
+
PS_COMMAND,
|
|
1973
|
+
[FORMAT_FLAG, START_TIME_FORMAT, PID_FLAG, String(pid)],
|
|
1974
|
+
{
|
|
1975
|
+
encoding: "utf8",
|
|
1976
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
1977
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1978
|
+
env: PROBE_ENVIRONMENT
|
|
1979
|
+
}
|
|
1980
|
+
).trim();
|
|
1981
|
+
return printed.length > 0 ? printed : void 0;
|
|
1982
|
+
} catch {
|
|
1983
|
+
return void 0;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
var START_PROBES = { read_proc_start, read_ps_start };
|
|
1987
|
+
function safe_probe(probe, pid) {
|
|
1988
|
+
try {
|
|
1989
|
+
return probe(pid);
|
|
1990
|
+
} catch {
|
|
1991
|
+
return void 0;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
function read_start(pid, probes = START_PROBES) {
|
|
1995
|
+
return safe_probe(probes.read_proc_start, pid) ?? safe_probe(probes.read_ps_start, pid);
|
|
1996
|
+
}
|
|
1997
|
+
function close_beacon(token) {
|
|
1998
|
+
const server = beacons.get(token);
|
|
1999
|
+
if (server === void 0) return;
|
|
2000
|
+
beacons.delete(token);
|
|
2001
|
+
server.close();
|
|
2002
|
+
rmSync(token.slice(BEACON_SCHEME.length), { force: true });
|
|
2003
|
+
}
|
|
2004
|
+
function close_beacons() {
|
|
2005
|
+
for (const token of beacons.keys()) close_beacon(token);
|
|
2006
|
+
}
|
|
2007
|
+
function ensure_exit_cleanup() {
|
|
2008
|
+
if (exit_cleanup.is_registered) return;
|
|
2009
|
+
exit_cleanup.is_registered = true;
|
|
2010
|
+
process.once("exit", close_beacons);
|
|
2011
|
+
}
|
|
2012
|
+
function ignore_beacon_error() {
|
|
2013
|
+
}
|
|
2014
|
+
function bind_beacon(target) {
|
|
2015
|
+
return createServer().on("error", ignore_beacon_error).listen(target);
|
|
2016
|
+
}
|
|
2017
|
+
function try_bind_beacon(bind, target) {
|
|
2018
|
+
try {
|
|
2019
|
+
const server = bind(target);
|
|
2020
|
+
return server.listening ? server : void 0;
|
|
2021
|
+
} catch {
|
|
2022
|
+
return void 0;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
function open_beacon(bind = bind_beacon) {
|
|
2026
|
+
const target = path2.join(PLATFORM_TEMP_ROOT, `${BEACON_PREFIX}${randomUUID()}${BEACON_SUFFIX}`);
|
|
2027
|
+
const token = `${BEACON_SCHEME}${target}`;
|
|
2028
|
+
const server = try_bind_beacon(bind, target);
|
|
2029
|
+
if (server === void 0) return void 0;
|
|
2030
|
+
server.unref();
|
|
2031
|
+
beacons.set(token, server);
|
|
2032
|
+
ensure_exit_cleanup();
|
|
2033
|
+
return token;
|
|
2034
|
+
}
|
|
2035
|
+
function resolve_own_start(probes = START_PROBES, platform2 = process.platform) {
|
|
2036
|
+
const start = read_start(process.pid, probes);
|
|
2037
|
+
if (start !== void 0 || platform2 === WINDOWS_PLATFORM) return start;
|
|
2038
|
+
return open_beacon();
|
|
2039
|
+
}
|
|
2040
|
+
function is_valid_beacon_name(name) {
|
|
2041
|
+
if (!name.startsWith(BEACON_PREFIX) || !name.endsWith(BEACON_SUFFIX)) return false;
|
|
2042
|
+
const id = name.slice(BEACON_PREFIX.length, -BEACON_SUFFIX.length);
|
|
2043
|
+
return id.length === BEACON_ID_LENGTH && BEACON_ID_PATTERN.test(id);
|
|
2044
|
+
}
|
|
2045
|
+
function beacon_target(token) {
|
|
2046
|
+
if (!token.startsWith(BEACON_SCHEME)) return void 0;
|
|
2047
|
+
const target = token.slice(BEACON_SCHEME.length);
|
|
2048
|
+
if (path2.dirname(target) !== PLATFORM_TEMP_ROOT) return void 0;
|
|
2049
|
+
return is_valid_beacon_name(path2.basename(target)) ? target : void 0;
|
|
2050
|
+
}
|
|
2051
|
+
function is_live_beacon(target) {
|
|
2052
|
+
return spawnSync(process.execPath, ["-e", SOCKET_PROBE_SOURCE, target], {
|
|
2053
|
+
stdio: "ignore",
|
|
2054
|
+
timeout: PROBE_TIMEOUT_MS
|
|
2055
|
+
}).status === 0;
|
|
2056
|
+
}
|
|
2057
|
+
function has_matching_start(pid, recorded_start, read2) {
|
|
2058
|
+
const observed = read2(pid);
|
|
2059
|
+
return observed === void 0 ? void 0 : observed === recorded_start;
|
|
2060
|
+
}
|
|
2061
|
+
function matches_recorded_start(pid, recorded_start, read2) {
|
|
2062
|
+
const target = beacon_target(recorded_start);
|
|
2063
|
+
return target === void 0 ? has_matching_start(pid, recorded_start, read2) : is_live_beacon(target);
|
|
2064
|
+
}
|
|
2065
|
+
var own_probe = /* @__PURE__ */ new Map();
|
|
2066
|
+
function own_start() {
|
|
2067
|
+
if (!own_probe.has(process.pid)) own_probe.set(process.pid, resolve_own_start());
|
|
2068
|
+
return own_probe.get(process.pid);
|
|
2069
|
+
}
|
|
2070
|
+
function own_fields() {
|
|
2071
|
+
const start = own_start();
|
|
2072
|
+
return { pid: process.pid, ...start !== void 0 && { process_start: start } };
|
|
2073
|
+
}
|
|
2074
|
+
function is_same_process(pid, recorded_start, read2 = read_start) {
|
|
2075
|
+
if (pid === void 0 || !is_live_pid(pid)) return false;
|
|
2076
|
+
if (recorded_start === void 0) return void 0;
|
|
2077
|
+
return matches_recorded_start(pid, recorded_start, read2);
|
|
2078
|
+
}
|
|
2079
|
+
function is_own_process(pid, recorded_start) {
|
|
2080
|
+
return pid === process.pid && recorded_start === own_start();
|
|
2081
|
+
}
|
|
2082
|
+
var process_identity = {
|
|
2083
|
+
close_beacon,
|
|
2084
|
+
is_live_pid,
|
|
2085
|
+
is_own_process,
|
|
2086
|
+
is_same_process,
|
|
2087
|
+
open_beacon,
|
|
2088
|
+
own_fields,
|
|
2089
|
+
own_start,
|
|
2090
|
+
read_start,
|
|
2091
|
+
resolve_own_start
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
// scripts/git/git-common-directory.ts
|
|
2095
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2096
|
+
var OWN_GIT_DIRECTORY = 0;
|
|
2097
|
+
var COMMON_GIT_DIRECTORY = 1;
|
|
2098
|
+
var PROBE_TIMEOUT_MS2 = 2e3;
|
|
2099
|
+
function read_directories(cwd) {
|
|
2100
|
+
const git = git_utilities.get_git_command_for_spawn();
|
|
2101
|
+
const output = execFileSync3(git, [...git_command.GIT_DIRECTORY_ARGUMENTS], {
|
|
2102
|
+
cwd,
|
|
2103
|
+
encoding: "utf8",
|
|
2104
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2105
|
+
timeout: PROBE_TIMEOUT_MS2
|
|
2106
|
+
});
|
|
2107
|
+
return output.trim().split("\n");
|
|
2108
|
+
}
|
|
2109
|
+
function select_linked(directories) {
|
|
2110
|
+
const own = directories[OWN_GIT_DIRECTORY];
|
|
2111
|
+
const common = directories[COMMON_GIT_DIRECTORY];
|
|
2112
|
+
return own !== void 0 && common !== void 0 && own !== common ? common : void 0;
|
|
2113
|
+
}
|
|
2114
|
+
function resolve2(cwd) {
|
|
2115
|
+
try {
|
|
2116
|
+
return select_linked(read_directories(cwd));
|
|
2117
|
+
} catch {
|
|
2118
|
+
return void 0;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
function repository(cwd) {
|
|
2122
|
+
try {
|
|
2123
|
+
return read_directories(cwd)[COMMON_GIT_DIRECTORY];
|
|
2124
|
+
} catch {
|
|
2125
|
+
return void 0;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
var git_common_directory = { repository, resolve: resolve2, select_linked };
|
|
2129
|
+
|
|
2130
|
+
// scripts/git/repository-lock.ts
|
|
2131
|
+
var POLL_INTERVAL_MS = 200;
|
|
2132
|
+
function lock_path(prefix, cwd = process.cwd()) {
|
|
2133
|
+
return stamp_file.stamp_path(prefix, git_common_directory.repository(cwd) ?? cwd);
|
|
2134
|
+
}
|
|
2135
|
+
function parse_owner(raw) {
|
|
2136
|
+
if (raw === void 0) return void 0;
|
|
2137
|
+
try {
|
|
2138
|
+
return JSON.parse(raw);
|
|
2139
|
+
} catch {
|
|
2140
|
+
return void 0;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
function read_owner(target) {
|
|
2144
|
+
return parse_owner(stamp_file.read_stamp_text(target));
|
|
2145
|
+
}
|
|
2146
|
+
function is_owner_gone(owner) {
|
|
2147
|
+
return process_identity.is_same_process(owner.pid, owner.process_start) === false;
|
|
2148
|
+
}
|
|
2149
|
+
function restore(aside, target) {
|
|
2150
|
+
try {
|
|
2151
|
+
linkSync(aside, target);
|
|
2152
|
+
} catch {
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
function stale_record(target, is_gone2) {
|
|
2156
|
+
const raw = stamp_file.read_stamp_text(target);
|
|
2157
|
+
const owner = parse_owner(raw);
|
|
2158
|
+
return owner !== void 0 && is_gone2(owner) ? raw : void 0;
|
|
2159
|
+
}
|
|
2160
|
+
function move_aside(target) {
|
|
2161
|
+
const aside = `${target}.${String(process.pid)}.${randomUUID2()}`;
|
|
2162
|
+
try {
|
|
2163
|
+
renameSync(target, aside);
|
|
2164
|
+
return aside;
|
|
2165
|
+
} catch {
|
|
2166
|
+
return void 0;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
function clear_stale(target, is_gone2 = is_owner_gone) {
|
|
2170
|
+
const raw = stale_record(target, is_gone2);
|
|
2171
|
+
if (raw === void 0) return;
|
|
2172
|
+
const aside = move_aside(target);
|
|
2173
|
+
if (aside === void 0) return;
|
|
2174
|
+
if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target);
|
|
2175
|
+
rmSync2(aside, { force: true });
|
|
2176
|
+
}
|
|
2177
|
+
function claim(target) {
|
|
2178
|
+
clear_stale(target);
|
|
2179
|
+
return stamp_file.create_stamp(target, process_identity.own_fields());
|
|
2180
|
+
}
|
|
2181
|
+
function release(target) {
|
|
2182
|
+
const owner = read_owner(target);
|
|
2183
|
+
if (owner !== void 0 && process_identity.is_own_process(owner.pid, owner.process_start)) {
|
|
2184
|
+
stamp_file.remove_stamp(target);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
async function acquire(target, max_wait_ms) {
|
|
2188
|
+
const deadline = Date.now() + max_wait_ms;
|
|
2189
|
+
while (!claim(target)) {
|
|
2190
|
+
if (Date.now() >= deadline) return false;
|
|
2191
|
+
await sleep(POLL_INTERVAL_MS);
|
|
2192
|
+
}
|
|
2193
|
+
return true;
|
|
2194
|
+
}
|
|
2195
|
+
async function with_lock(work, target, max_wait_ms) {
|
|
2196
|
+
if (!await acquire(target, max_wait_ms)) return void 0;
|
|
2197
|
+
try {
|
|
2198
|
+
return await work();
|
|
2199
|
+
} finally {
|
|
2200
|
+
release(target);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
var repository_lock = { clear_stale, lock_path, with_lock };
|
|
2204
|
+
|
|
1921
2205
|
// scripts/git/git-worktree.ts
|
|
1922
2206
|
var WORKTREE = "worktree";
|
|
2207
|
+
var WORKTREE_LOCK_PREFIX = "josh-worktree-lock-";
|
|
2208
|
+
var WORKTREE_LOCK_MAX_WAIT_MS = 12e4;
|
|
2209
|
+
async function locked(arguments_) {
|
|
2210
|
+
const target = repository_lock.lock_path(WORKTREE_LOCK_PREFIX);
|
|
2211
|
+
const output = await repository_lock.with_lock(
|
|
2212
|
+
async () => await git_spawn.read(arguments_),
|
|
2213
|
+
target,
|
|
2214
|
+
WORKTREE_LOCK_MAX_WAIT_MS
|
|
2215
|
+
);
|
|
2216
|
+
if (output === void 0) {
|
|
2217
|
+
throw new Error(
|
|
2218
|
+
`git ${arguments_.join(" ")}: timed out waiting for the work-tree lock ${target}`
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2221
|
+
return output;
|
|
2222
|
+
}
|
|
1923
2223
|
async function worktree_list() {
|
|
1924
2224
|
return await git_spawn.read([WORKTREE, "list", PORCELAIN_FLAG]);
|
|
1925
2225
|
}
|
|
1926
2226
|
async function worktree_add(directory, branch_name, start_point) {
|
|
1927
2227
|
const flags = start_point === void 0 ? ["add"] : ["add", "--no-track", "-b", branch_name];
|
|
1928
|
-
return await
|
|
2228
|
+
return await locked([WORKTREE, ...flags, directory, start_point ?? branch_name]);
|
|
1929
2229
|
}
|
|
1930
2230
|
async function worktree_add_detached(directory, commit2) {
|
|
1931
|
-
return await
|
|
2231
|
+
return await locked([WORKTREE, "add", "--detach", directory, commit2]);
|
|
1932
2232
|
}
|
|
1933
2233
|
async function worktree_remove(directory) {
|
|
1934
|
-
return await
|
|
2234
|
+
return await locked([WORKTREE, "remove", "--force", directory]);
|
|
1935
2235
|
}
|
|
1936
2236
|
async function worktree_prune() {
|
|
1937
|
-
return await
|
|
2237
|
+
return await locked([WORKTREE, "prune"]);
|
|
1938
2238
|
}
|
|
1939
2239
|
async function ls_remote_branch(branch_name) {
|
|
1940
2240
|
return await git_spawn.read(ls_remote_branch_arguments(branch_name));
|
|
@@ -2097,7 +2397,7 @@ function held_seat(content) {
|
|
|
2097
2397
|
}
|
|
2098
2398
|
function read_environment(directory) {
|
|
2099
2399
|
try {
|
|
2100
|
-
return
|
|
2400
|
+
return readFileSync4(path3.join(directory, ENV_FILE_NAME), "utf8");
|
|
2101
2401
|
} catch {
|
|
2102
2402
|
return void 0;
|
|
2103
2403
|
}
|
|
@@ -2463,174 +2763,6 @@ var run_invocation = {
|
|
|
2463
2763
|
rebuild
|
|
2464
2764
|
};
|
|
2465
2765
|
|
|
2466
|
-
// scripts/josh/process-identity.ts
|
|
2467
|
-
import { execFileSync as execFileSync2, spawnSync } from "node:child_process";
|
|
2468
|
-
import { randomUUID } from "node:crypto";
|
|
2469
|
-
import { readFileSync as readFileSync4, rmSync } from "node:fs";
|
|
2470
|
-
import { createServer } from "node:net";
|
|
2471
|
-
import path3 from "node:path";
|
|
2472
|
-
var PS_COMMAND = "/bin/ps";
|
|
2473
|
-
var FORMAT_FLAG = "-o";
|
|
2474
|
-
var START_TIME_FORMAT = "lstart=";
|
|
2475
|
-
var PID_FLAG = "-p";
|
|
2476
|
-
var PROBE_ENVIRONMENT = { TZ: "UTC", LC_ALL: "C" };
|
|
2477
|
-
var PROBE_TIMEOUT_MS = 2e3;
|
|
2478
|
-
var LIVENESS_SIGNAL = 0;
|
|
2479
|
-
var LOWEST_REAL_PID = 1;
|
|
2480
|
-
var PROC_START_FIELD_INDEX = 19;
|
|
2481
|
-
var BEACON_SCHEME = "socket:";
|
|
2482
|
-
var BEACON_SUFFIX = ".sock";
|
|
2483
|
-
var BEACON_PREFIX = `josh-process-identity-${String(process.getuid?.() ?? "")}-`;
|
|
2484
|
-
var BEACON_ID_LENGTH = 36;
|
|
2485
|
-
var BEACON_ID_PATTERN = /^[\da-f-]+$/u;
|
|
2486
|
-
var WINDOWS_PLATFORM = "win32";
|
|
2487
|
-
var SOCKET_PROBE_SOURCE = "const net=require('node:net');const socket=net.createConnection(process.argv[1]);socket.once('connect',()=>process.exit(0));socket.once('error',()=>process.exit(1));socket.setTimeout(1000,()=>process.exit(1));";
|
|
2488
|
-
var beacons = /* @__PURE__ */ new Map();
|
|
2489
|
-
var exit_cleanup = { is_registered: false };
|
|
2490
|
-
function is_live_pid(pid) {
|
|
2491
|
-
if (pid < LOWEST_REAL_PID) return false;
|
|
2492
|
-
try {
|
|
2493
|
-
process.kill(pid, LIVENESS_SIGNAL);
|
|
2494
|
-
return true;
|
|
2495
|
-
} catch {
|
|
2496
|
-
return false;
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
function read_proc_start(pid) {
|
|
2500
|
-
try {
|
|
2501
|
-
const stat = readFileSync4(`/proc/${String(pid)}/stat`, "utf8");
|
|
2502
|
-
const fields = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/u);
|
|
2503
|
-
const start = fields[PROC_START_FIELD_INDEX];
|
|
2504
|
-
return start === void 0 ? void 0 : `proc:${start}`;
|
|
2505
|
-
} catch {
|
|
2506
|
-
return void 0;
|
|
2507
|
-
}
|
|
2508
|
-
}
|
|
2509
|
-
function read_ps_start(pid) {
|
|
2510
|
-
try {
|
|
2511
|
-
const printed = execFileSync2(
|
|
2512
|
-
PS_COMMAND,
|
|
2513
|
-
[FORMAT_FLAG, START_TIME_FORMAT, PID_FLAG, String(pid)],
|
|
2514
|
-
{
|
|
2515
|
-
encoding: "utf8",
|
|
2516
|
-
timeout: PROBE_TIMEOUT_MS,
|
|
2517
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2518
|
-
env: PROBE_ENVIRONMENT
|
|
2519
|
-
}
|
|
2520
|
-
).trim();
|
|
2521
|
-
return printed.length > 0 ? printed : void 0;
|
|
2522
|
-
} catch {
|
|
2523
|
-
return void 0;
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
var START_PROBES = { read_proc_start, read_ps_start };
|
|
2527
|
-
function safe_probe(probe, pid) {
|
|
2528
|
-
try {
|
|
2529
|
-
return probe(pid);
|
|
2530
|
-
} catch {
|
|
2531
|
-
return void 0;
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
|
-
function read_start(pid, probes = START_PROBES) {
|
|
2535
|
-
return safe_probe(probes.read_proc_start, pid) ?? safe_probe(probes.read_ps_start, pid);
|
|
2536
|
-
}
|
|
2537
|
-
function close_beacon(token) {
|
|
2538
|
-
const server = beacons.get(token);
|
|
2539
|
-
if (server === void 0) return;
|
|
2540
|
-
beacons.delete(token);
|
|
2541
|
-
server.close();
|
|
2542
|
-
rmSync(token.slice(BEACON_SCHEME.length), { force: true });
|
|
2543
|
-
}
|
|
2544
|
-
function close_beacons() {
|
|
2545
|
-
for (const token of beacons.keys()) close_beacon(token);
|
|
2546
|
-
}
|
|
2547
|
-
function ensure_exit_cleanup() {
|
|
2548
|
-
if (exit_cleanup.is_registered) return;
|
|
2549
|
-
exit_cleanup.is_registered = true;
|
|
2550
|
-
process.once("exit", close_beacons);
|
|
2551
|
-
}
|
|
2552
|
-
function ignore_beacon_error() {
|
|
2553
|
-
}
|
|
2554
|
-
function bind_beacon(target) {
|
|
2555
|
-
return createServer().on("error", ignore_beacon_error).listen(target);
|
|
2556
|
-
}
|
|
2557
|
-
function try_bind_beacon(bind, target) {
|
|
2558
|
-
try {
|
|
2559
|
-
const server = bind(target);
|
|
2560
|
-
return server.listening ? server : void 0;
|
|
2561
|
-
} catch {
|
|
2562
|
-
return void 0;
|
|
2563
|
-
}
|
|
2564
|
-
}
|
|
2565
|
-
function open_beacon(bind = bind_beacon) {
|
|
2566
|
-
const target = path3.join(PLATFORM_TEMP_ROOT, `${BEACON_PREFIX}${randomUUID()}${BEACON_SUFFIX}`);
|
|
2567
|
-
const token = `${BEACON_SCHEME}${target}`;
|
|
2568
|
-
const server = try_bind_beacon(bind, target);
|
|
2569
|
-
if (server === void 0) return void 0;
|
|
2570
|
-
server.unref();
|
|
2571
|
-
beacons.set(token, server);
|
|
2572
|
-
ensure_exit_cleanup();
|
|
2573
|
-
return token;
|
|
2574
|
-
}
|
|
2575
|
-
function resolve_own_start(probes = START_PROBES, platform2 = process.platform) {
|
|
2576
|
-
const start = read_start(process.pid, probes);
|
|
2577
|
-
if (start !== void 0 || platform2 === WINDOWS_PLATFORM) return start;
|
|
2578
|
-
return open_beacon();
|
|
2579
|
-
}
|
|
2580
|
-
function is_valid_beacon_name(name) {
|
|
2581
|
-
if (!name.startsWith(BEACON_PREFIX) || !name.endsWith(BEACON_SUFFIX)) return false;
|
|
2582
|
-
const id = name.slice(BEACON_PREFIX.length, -BEACON_SUFFIX.length);
|
|
2583
|
-
return id.length === BEACON_ID_LENGTH && BEACON_ID_PATTERN.test(id);
|
|
2584
|
-
}
|
|
2585
|
-
function beacon_target(token) {
|
|
2586
|
-
if (!token.startsWith(BEACON_SCHEME)) return void 0;
|
|
2587
|
-
const target = token.slice(BEACON_SCHEME.length);
|
|
2588
|
-
if (path3.dirname(target) !== PLATFORM_TEMP_ROOT) return void 0;
|
|
2589
|
-
return is_valid_beacon_name(path3.basename(target)) ? target : void 0;
|
|
2590
|
-
}
|
|
2591
|
-
function is_live_beacon(target) {
|
|
2592
|
-
return spawnSync(process.execPath, ["-e", SOCKET_PROBE_SOURCE, target], {
|
|
2593
|
-
stdio: "ignore",
|
|
2594
|
-
timeout: PROBE_TIMEOUT_MS
|
|
2595
|
-
}).status === 0;
|
|
2596
|
-
}
|
|
2597
|
-
function has_matching_start(pid, recorded_start, read2) {
|
|
2598
|
-
const observed = read2(pid);
|
|
2599
|
-
return observed === void 0 ? void 0 : observed === recorded_start;
|
|
2600
|
-
}
|
|
2601
|
-
function matches_recorded_start(pid, recorded_start, read2) {
|
|
2602
|
-
const target = beacon_target(recorded_start);
|
|
2603
|
-
return target === void 0 ? has_matching_start(pid, recorded_start, read2) : is_live_beacon(target);
|
|
2604
|
-
}
|
|
2605
|
-
var own_probe = /* @__PURE__ */ new Map();
|
|
2606
|
-
function own_start() {
|
|
2607
|
-
if (!own_probe.has(process.pid)) own_probe.set(process.pid, resolve_own_start());
|
|
2608
|
-
return own_probe.get(process.pid);
|
|
2609
|
-
}
|
|
2610
|
-
function own_fields() {
|
|
2611
|
-
const start = own_start();
|
|
2612
|
-
return { pid: process.pid, ...start !== void 0 && { process_start: start } };
|
|
2613
|
-
}
|
|
2614
|
-
function is_same_process(pid, recorded_start, read2 = read_start) {
|
|
2615
|
-
if (pid === void 0 || !is_live_pid(pid)) return false;
|
|
2616
|
-
if (recorded_start === void 0) return void 0;
|
|
2617
|
-
return matches_recorded_start(pid, recorded_start, read2);
|
|
2618
|
-
}
|
|
2619
|
-
function is_own_process(pid, recorded_start) {
|
|
2620
|
-
return pid === process.pid && recorded_start === own_start();
|
|
2621
|
-
}
|
|
2622
|
-
var process_identity = {
|
|
2623
|
-
close_beacon,
|
|
2624
|
-
is_live_pid,
|
|
2625
|
-
is_own_process,
|
|
2626
|
-
is_same_process,
|
|
2627
|
-
open_beacon,
|
|
2628
|
-
own_fields,
|
|
2629
|
-
own_start,
|
|
2630
|
-
read_start,
|
|
2631
|
-
resolve_own_start
|
|
2632
|
-
};
|
|
2633
|
-
|
|
2634
2766
|
// scripts/run/run-carry.ts
|
|
2635
2767
|
import { z as z5 } from "zod";
|
|
2636
2768
|
|
|
@@ -3404,8 +3536,8 @@ export {
|
|
|
3404
3536
|
gh_failure,
|
|
3405
3537
|
has_stderr_field,
|
|
3406
3538
|
git_gh_exec,
|
|
3407
|
-
lane_registry,
|
|
3408
3539
|
process_identity,
|
|
3540
|
+
lane_registry,
|
|
3409
3541
|
run_invocation,
|
|
3410
3542
|
run_carry,
|
|
3411
3543
|
run_watcher_guard
|
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
pretool_guard,
|
|
4
4
|
pretool_outcome,
|
|
5
5
|
pretool_outcome_async
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-Z5TVZ4AP.js";
|
|
7
|
+
import "./chunk-452OKRQX.js";
|
|
8
8
|
import "./chunk-3V37IID6.js";
|
|
9
9
|
import "./chunk-V7RH56HU.js";
|
|
10
10
|
import "./chunk-NKVLBB65.js";
|
package/dist/hooks/stop-guard.js
CHANGED
package/dist/version/index.js
CHANGED
|
@@ -163,9 +163,12 @@ var NO_QUARANTINE_MINUTES = 0;
|
|
|
163
163
|
var NPMRC_PATH = ".npmrc";
|
|
164
164
|
var release_times_schema = z2.record(z2.string(), z2.string());
|
|
165
165
|
var MS_PER_MINUTE = 6e4;
|
|
166
|
-
function
|
|
166
|
+
function parse_declared_minimum_release_age(npmrc_content) {
|
|
167
167
|
const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1];
|
|
168
|
-
return raw === void 0 ?
|
|
168
|
+
return raw === void 0 ? void 0 : Number(raw);
|
|
169
|
+
}
|
|
170
|
+
function parse_minimum_release_age(npmrc_content) {
|
|
171
|
+
return parse_declared_minimum_release_age(npmrc_content) ?? NO_QUARANTINE_MINUTES;
|
|
169
172
|
}
|
|
170
173
|
function is_on_major(version, major) {
|
|
171
174
|
return major === void 0 || String(semver.major(version)) === major;
|
|
@@ -184,8 +187,7 @@ function select_aged_version(times, major, minimum_age_minutes, now_ms) {
|
|
|
184
187
|
}
|
|
185
188
|
function read_declared_minimum_release_age(npmrc_path) {
|
|
186
189
|
try {
|
|
187
|
-
|
|
188
|
-
return raw === void 0 ? void 0 : Number(raw);
|
|
190
|
+
return parse_declared_minimum_release_age(readFileSync2(npmrc_path, "utf8"));
|
|
189
191
|
} catch {
|
|
190
192
|
return void 0;
|
|
191
193
|
}
|
|
@@ -206,6 +208,7 @@ function read_nearest_minimum_release_age(start, boundary) {
|
|
|
206
208
|
var release_age = {
|
|
207
209
|
release_times_schema,
|
|
208
210
|
parse_minimum_release_age,
|
|
211
|
+
parse_declared_minimum_release_age,
|
|
209
212
|
read_minimum_release_age,
|
|
210
213
|
read_nearest_minimum_release_age,
|
|
211
214
|
select_aged_version
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.953.0",
|
|
3
3
|
"name": "@joshuafolkken/kit",
|
|
4
4
|
"description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
|
|
5
5
|
"keywords": [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PORCELAIN_FLAG } from './constants'
|
|
2
2
|
import { ls_remote_branch_arguments } from './git-ls-remote'
|
|
3
3
|
import { git_spawn } from './git-spawn'
|
|
4
|
+
import { repository_lock } from './repository-lock'
|
|
4
5
|
|
|
5
6
|
// The four worktree reads and writes a lane's lifecycle needs, plus the branch deletion that ends it
|
|
6
7
|
// (joshuafolkken/kit#1490; split out of `git-command.ts` under joshuafolkken/kit#1640). They live
|
|
@@ -19,6 +20,36 @@ import { git_spawn } from './git-spawn'
|
|
|
19
20
|
// (joshuafolkken/kit#1641; before it, `lane-start-point.ts` was the only reader and did both).
|
|
20
21
|
const WORKTREE = 'worktree'
|
|
21
22
|
|
|
23
|
+
// **Every call that writes `.git/worktrees/` runs under one repository-wide lock**
|
|
24
|
+
// (joshuafolkken/kit#2736). `git worktree add` is not safe against itself: while one add is still
|
|
25
|
+
// building `.git/worktrees/<id>/` — its `commondir` not yet written — a second add enumerates the
|
|
26
|
+
// existing registrations, reads the half-built one and fails with `fatal: failed to read
|
|
27
|
+
// .git/worktrees/<id>/commondir`. Two lanes opened at once do exactly that, and the per-seat lock in
|
|
28
|
+
// `lane-open.ts` does not serialize them. `remove` and `prune` edit the same directory, so they take
|
|
29
|
+
// the same lock. The lock wraps the git call alone; the slow steps a caller runs afterwards — a lane's
|
|
30
|
+
// dependency install — stay outside it.
|
|
31
|
+
const WORKTREE_LOCK_PREFIX = 'josh-worktree-lock-'
|
|
32
|
+
// A holder runs one local git command, so the wait only bounds a checkout that hung; it is generous
|
|
33
|
+
// because a large repository's checkout is itself several seconds, and several lanes queue behind it.
|
|
34
|
+
const WORKTREE_LOCK_MAX_WAIT_MS = 120_000
|
|
35
|
+
|
|
36
|
+
async function locked(arguments_: Array<string>): Promise<string> {
|
|
37
|
+
const target = repository_lock.lock_path(WORKTREE_LOCK_PREFIX)
|
|
38
|
+
const output = await repository_lock.with_lock(
|
|
39
|
+
async () => await git_spawn.read(arguments_),
|
|
40
|
+
target,
|
|
41
|
+
WORKTREE_LOCK_MAX_WAIT_MS,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (output === undefined) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`git ${arguments_.join(' ')}: timed out waiting for the work-tree lock ${target}`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return output
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
// Every registered work tree of this repository, in git's own machine-readable form: one `worktree
|
|
23
54
|
// <path>` / `HEAD <sha>` / `branch <ref>` block per tree, blocks separated by a blank line. Parsing
|
|
24
55
|
// it is the caller's, so this module keeps one shape for every reader.
|
|
@@ -50,14 +81,14 @@ async function worktree_add(
|
|
|
50
81
|
): Promise<string> {
|
|
51
82
|
const flags = start_point === undefined ? ['add'] : ['add', '--no-track', '-b', branch_name]
|
|
52
83
|
|
|
53
|
-
return await
|
|
84
|
+
return await locked([WORKTREE, ...flags, directory, start_point ?? branch_name])
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
// A throwaway tree at one commit, on no branch — `josh test:red` checks the merge-base out here, so
|
|
57
88
|
// the pre-fix tree is built without creating a branch or touching the caller's tree and index
|
|
58
89
|
// (joshuafolkken/kit#2448).
|
|
59
90
|
async function worktree_add_detached(directory: string, commit: string): Promise<string> {
|
|
60
|
-
return await
|
|
91
|
+
return await locked([WORKTREE, 'add', '--detach', directory, commit])
|
|
61
92
|
}
|
|
62
93
|
|
|
63
94
|
// **`--force` is the point, not a convenience.** A lane is closed after a park, a failure or an
|
|
@@ -65,13 +96,13 @@ async function worktree_add_detached(directory: string, commit: string): Promise
|
|
|
65
96
|
// or untracked work. Refusing to remove it there would leave exactly the debris the close exists to
|
|
66
97
|
// prevent.
|
|
67
98
|
async function worktree_remove(directory: string): Promise<string> {
|
|
68
|
-
return await
|
|
99
|
+
return await locked([WORKTREE, 'remove', '--force', directory])
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
// Drops the registrations whose directories are already gone — what makes a lane whose directory was
|
|
72
103
|
// deleted by hand recoverable rather than a permanent `worktree add` refusal on that path.
|
|
73
104
|
async function worktree_prune(): Promise<string> {
|
|
74
|
-
return await
|
|
105
|
+
return await locked([WORKTREE, 'prune'])
|
|
75
106
|
}
|
|
76
107
|
|
|
77
108
|
// **Asked of the remote itself, because nothing prunes the remote-tracking refs**
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { linkSync, renameSync, rmSync } from 'node:fs'
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
|
+
import { process_identity } from '#scripts/josh/process-identity'
|
|
5
|
+
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
6
|
+
import { git_common_directory } from './git-common-directory'
|
|
7
|
+
|
|
8
|
+
// One holder at a time per repository, for work that every work tree of that repository shares
|
|
9
|
+
// (joshuafolkken/kit#2701 for the stash sweep, joshuafolkken/kit#2736 for `git worktree add`). The
|
|
10
|
+
// record is keyed on the git common directory, so every work tree of one repository contends for one
|
|
11
|
+
// record per lock name, and it lives in the platform temp root beside the other stamps rather than
|
|
12
|
+
// inside `.git`. Each caller names its own lock with a prefix, so two unrelated kinds of work never
|
|
13
|
+
// wait on each other.
|
|
14
|
+
|
|
15
|
+
const POLL_INTERVAL_MS = 200
|
|
16
|
+
|
|
17
|
+
interface LockOwner {
|
|
18
|
+
pid: number
|
|
19
|
+
process_start?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type IsGone = (owner: LockOwner) => boolean
|
|
23
|
+
|
|
24
|
+
function lock_path(prefix: string, cwd: string = process.cwd()): string {
|
|
25
|
+
return stamp_file.stamp_path(prefix, git_common_directory.repository(cwd) ?? cwd)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parse_owner(raw: string | undefined): LockOwner | undefined {
|
|
29
|
+
if (raw === undefined) return undefined
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(raw) as LockOwner
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function read_owner(target: string): LockOwner | undefined {
|
|
39
|
+
return parse_owner(stamp_file.read_stamp_text(target))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function is_owner_gone(owner: LockOwner): boolean {
|
|
43
|
+
return process_identity.is_same_process(owner.pid, owner.process_start) === false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Put back a record that turned out not to be the stale one. `linkSync` refuses an existing path, so
|
|
47
|
+
// a lock someone claimed in the meantime is never overwritten.
|
|
48
|
+
function restore(aside: string, target: string): void {
|
|
49
|
+
try {
|
|
50
|
+
linkSync(aside, target)
|
|
51
|
+
} catch {
|
|
52
|
+
// Another claim already holds the path; the record set aside is dropped below.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The record's text when its owner is certainly gone, else `undefined`.
|
|
57
|
+
function stale_record(target: string, is_gone: IsGone): string | undefined {
|
|
58
|
+
const raw = stamp_file.read_stamp_text(target)
|
|
59
|
+
const owner = parse_owner(raw)
|
|
60
|
+
|
|
61
|
+
return owner !== undefined && is_gone(owner) ? raw : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The path the record was moved to, or `undefined` when another claimant moved it first.
|
|
65
|
+
function move_aside(target: string): string | undefined {
|
|
66
|
+
const aside = `${target}.${String(process.pid)}.${randomUUID()}`
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
renameSync(target, aside)
|
|
70
|
+
|
|
71
|
+
return aside
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A record whose process is certainly gone is a crashed holder's, and is cleared before the claim; an
|
|
78
|
+
// unreadable record or an uncertain answer is left in place, since clearing a live holder's lock is the
|
|
79
|
+
// very race the lock exists to close. **The clear is a rename, then a compare**: two claimants can
|
|
80
|
+
// both judge the same record stale, and the slower one's plain remove would delete the lock the faster
|
|
81
|
+
// one had just claimed. Renaming moves exactly one file aside atomically, and only a record
|
|
82
|
+
// byte-identical to the one judged stale is discarded — any other is a fresh claim and goes back.
|
|
83
|
+
function clear_stale(target: string, is_gone: IsGone = is_owner_gone): void {
|
|
84
|
+
const raw = stale_record(target, is_gone)
|
|
85
|
+
|
|
86
|
+
if (raw === undefined) return
|
|
87
|
+
|
|
88
|
+
const aside = move_aside(target)
|
|
89
|
+
|
|
90
|
+
if (aside === undefined) return
|
|
91
|
+
if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target)
|
|
92
|
+
rmSync(aside, { force: true })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function claim(target: string): boolean {
|
|
96
|
+
clear_stale(target)
|
|
97
|
+
|
|
98
|
+
return stamp_file.create_stamp(target, process_identity.own_fields())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function release(target: string): void {
|
|
102
|
+
const owner = read_owner(target)
|
|
103
|
+
|
|
104
|
+
if (owner !== undefined && process_identity.is_own_process(owner.pid, owner.process_start)) {
|
|
105
|
+
stamp_file.remove_stamp(target)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function acquire(target: string, max_wait_ms: number): Promise<boolean> {
|
|
110
|
+
const deadline = Date.now() + max_wait_ms
|
|
111
|
+
|
|
112
|
+
while (!claim(target)) {
|
|
113
|
+
if (Date.now() >= deadline) return false
|
|
114
|
+
await sleep(POLL_INTERVAL_MS)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Runs `work` holding the lock at `target` and answers its result, or `undefined` without running it
|
|
121
|
+
// when another holder kept the lock for the whole wait.
|
|
122
|
+
async function with_lock<T>(
|
|
123
|
+
work: () => Promise<T>,
|
|
124
|
+
target: string,
|
|
125
|
+
max_wait_ms: number,
|
|
126
|
+
): Promise<T | undefined> {
|
|
127
|
+
if (!(await acquire(target, max_wait_ms))) return undefined
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
return await work()
|
|
131
|
+
} finally {
|
|
132
|
+
release(target)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const repository_lock = { clear_stale, lock_path, with_lock }
|
|
137
|
+
|
|
138
|
+
export { repository_lock }
|
|
@@ -1,123 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { linkSync, renameSync, rmSync } from 'node:fs'
|
|
3
|
-
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
|
-
import { process_identity } from '#scripts/josh/process-identity'
|
|
5
|
-
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
6
|
-
import { git_common_directory } from './git-common-directory'
|
|
1
|
+
import { repository_lock } from './repository-lock'
|
|
7
2
|
|
|
8
3
|
// One stash sweep at a time per repository (joshuafolkken/kit#2701). The stash is a single stack every
|
|
9
4
|
// work tree shares, and a `backlogrun` runs one sweep per lane seat at once: two sweeps that each
|
|
10
5
|
// resolve the same entry to `stash@{n}` and drop it would have the second drop land on whichever entry
|
|
11
|
-
// moved into that position, and two that carry the same ledger lines would both append them. The
|
|
12
|
-
//
|
|
6
|
+
// moved into that position, and two that carry the same ledger lines would both append them. The
|
|
7
|
+
// locking itself is `repository-lock.ts`, shared with the work-tree writes (joshuafolkken/kit#2736).
|
|
13
8
|
|
|
14
9
|
const LOCK_PREFIX = 'josh-stash-sweep-lock-'
|
|
15
10
|
// The locked phase is local git and one file append per entry, so a holder finishes in well under the
|
|
16
11
|
// wait; the cap only bounds a holder that hung, and a sweep that gives up keeps its entries.
|
|
17
|
-
const POLL_INTERVAL_MS = 200
|
|
18
12
|
const MAX_WAIT_MS = 30_000
|
|
19
13
|
|
|
20
|
-
interface LockOwner {
|
|
21
|
-
pid: number
|
|
22
|
-
process_start?: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
14
|
function lock_path(cwd: string = process.cwd()): string {
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
type IsGone = (owner: LockOwner) => boolean
|
|
30
|
-
|
|
31
|
-
function parse_owner(raw: string | undefined): LockOwner | undefined {
|
|
32
|
-
if (raw === undefined) return undefined
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
return JSON.parse(raw) as LockOwner
|
|
36
|
-
} catch {
|
|
37
|
-
return undefined
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function read_owner(target: string): LockOwner | undefined {
|
|
42
|
-
return parse_owner(stamp_file.read_stamp_text(target))
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function is_owner_gone(owner: LockOwner): boolean {
|
|
46
|
-
return process_identity.is_same_process(owner.pid, owner.process_start) === false
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Put back a record that turned out not to be the stale one. `linkSync` refuses an existing path, so
|
|
50
|
-
// a lock someone claimed in the meantime is never overwritten.
|
|
51
|
-
function restore(aside: string, target: string): void {
|
|
52
|
-
try {
|
|
53
|
-
linkSync(aside, target)
|
|
54
|
-
} catch {
|
|
55
|
-
// Another claim already holds the path; the record set aside is dropped below.
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// The record's text when its owner is certainly gone, else `undefined`.
|
|
60
|
-
function stale_record(target: string, is_gone: IsGone): string | undefined {
|
|
61
|
-
const raw = stamp_file.read_stamp_text(target)
|
|
62
|
-
const owner = parse_owner(raw)
|
|
63
|
-
|
|
64
|
-
return owner !== undefined && is_gone(owner) ? raw : undefined
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// The path the record was moved to, or `undefined` when another sweep moved it first.
|
|
68
|
-
function move_aside(target: string): string | undefined {
|
|
69
|
-
const aside = `${target}.${String(process.pid)}.${randomUUID()}`
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
renameSync(target, aside)
|
|
73
|
-
|
|
74
|
-
return aside
|
|
75
|
-
} catch {
|
|
76
|
-
return undefined
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// A record whose process is certainly gone is a crashed sweep's, and is cleared before the claim; an
|
|
81
|
-
// unreadable record or an uncertain answer is left in place, since clearing a live holder's lock is the
|
|
82
|
-
// very race the lock exists to close. **The clear is a rename, then a compare**: two sweeps can both
|
|
83
|
-
// judge the same record stale, and the slower one's plain remove would delete the lock the faster one
|
|
84
|
-
// had just claimed. Renaming moves exactly one file aside atomically, and only a record byte-identical
|
|
85
|
-
// to the one judged stale is discarded — any other is a fresh claim and goes back.
|
|
86
|
-
function clear_stale(target: string, is_gone: IsGone = is_owner_gone): void {
|
|
87
|
-
const raw = stale_record(target, is_gone)
|
|
88
|
-
|
|
89
|
-
if (raw === undefined) return
|
|
90
|
-
|
|
91
|
-
const aside = move_aside(target)
|
|
92
|
-
|
|
93
|
-
if (aside === undefined) return
|
|
94
|
-
if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target)
|
|
95
|
-
rmSync(aside, { force: true })
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function claim(target: string): boolean {
|
|
99
|
-
clear_stale(target)
|
|
100
|
-
|
|
101
|
-
return stamp_file.create_stamp(target, process_identity.own_fields())
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function release(target: string): void {
|
|
105
|
-
const owner = read_owner(target)
|
|
106
|
-
|
|
107
|
-
if (owner !== undefined && process_identity.is_own_process(owner.pid, owner.process_start)) {
|
|
108
|
-
stamp_file.remove_stamp(target)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function acquire(target: string, max_wait_ms: number): Promise<boolean> {
|
|
113
|
-
const deadline = Date.now() + max_wait_ms
|
|
114
|
-
|
|
115
|
-
while (!claim(target)) {
|
|
116
|
-
if (Date.now() >= deadline) return false
|
|
117
|
-
await sleep(POLL_INTERVAL_MS)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return true
|
|
15
|
+
return repository_lock.lock_path(LOCK_PREFIX, cwd)
|
|
121
16
|
}
|
|
122
17
|
|
|
123
18
|
// Runs `work` holding the lock and answers its result, or `undefined` without running it when another
|
|
@@ -127,15 +22,9 @@ async function with_lock<T>(
|
|
|
127
22
|
target: string = lock_path(),
|
|
128
23
|
max_wait_ms: number = MAX_WAIT_MS,
|
|
129
24
|
): Promise<T | undefined> {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
return await work()
|
|
134
|
-
} finally {
|
|
135
|
-
release(target)
|
|
136
|
-
}
|
|
25
|
+
return await repository_lock.with_lock(work, target, max_wait_ms)
|
|
137
26
|
}
|
|
138
27
|
|
|
139
|
-
const stash_sweep_lock = { clear_stale, lock_path, with_lock }
|
|
28
|
+
const stash_sweep_lock = { clear_stale: repository_lock.clear_stale, lock_path, with_lock }
|
|
140
29
|
|
|
141
30
|
export { stash_sweep_lock }
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { release_age } from '#scripts/version/release-age'
|
|
3
4
|
import { yaml_document } from '#scripts/yaml/yaml-document'
|
|
4
5
|
import { dump } from 'js-yaml'
|
|
5
6
|
import { z } from 'zod'
|
|
6
7
|
|
|
7
8
|
const WORKSPACE_FILE = 'pnpm-workspace.yaml'
|
|
8
9
|
const PROJECT_CONFIG_FILE = '.aikido'
|
|
10
|
+
const NPMRC_FILE = '.npmrc'
|
|
9
11
|
const EXCLUSIONS_FIELD = 'minimumPackageAgeExclusions'
|
|
12
|
+
const AGE_FIELD = 'minimumPackageAgeHours'
|
|
13
|
+
const MINUTES_PER_HOUR = 60
|
|
10
14
|
const workspace_schema = z.object({ minimumReleaseAgeExclude: z.array(z.string()).optional() })
|
|
11
15
|
|
|
12
16
|
function read_mapping(value: unknown, label: string): Record<string, unknown> {
|
|
@@ -72,27 +76,68 @@ function replace_safe_chain_block(existing: string, safe_chain: Record<string, u
|
|
|
72
76
|
return `${prefix}${prefix ? '\n' : ''}${replacement}${suffix}`
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// Safe Chain enforces its own minimum package age (48 hours by default), so CI refused lockfiles pnpm
|
|
80
|
+
// had resolved under `.npmrc`'s shorter window (kit #2743). `.npmrc` is the source: its minutes are
|
|
81
|
+
// floored to whole hours so Safe Chain is never stricter than pnpm, and an undeclared window writes
|
|
82
|
+
// nothing; an explicit `0` opt-out is written as 0 so an earlier synchronized age cannot outlive it.
|
|
83
|
+
function package_age_hours(npmrc: string): number | undefined {
|
|
84
|
+
const minutes = release_age.parse_declared_minimum_release_age(npmrc)
|
|
85
|
+
if (minutes === undefined) return undefined
|
|
86
|
+
|
|
87
|
+
return Math.floor(minutes / MINUTES_PER_HOUR)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function merge_age(safe_chain: Record<string, unknown>, npmrc: string): Record<string, unknown> {
|
|
91
|
+
const hours = package_age_hours(npmrc)
|
|
92
|
+
if (hours === undefined) return safe_chain
|
|
93
|
+
|
|
94
|
+
return { ...safe_chain, [AGE_FIELD]: hours }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function merge_exclusions(
|
|
98
|
+
safe_chain: Record<string, unknown>,
|
|
99
|
+
exclusions: ReadonlyArray<string>,
|
|
100
|
+
): Record<string, unknown> {
|
|
80
101
|
const npm = read_mapping(safe_chain['npm'], 'safe-chain.npm')
|
|
81
|
-
if (JSON.stringify(npm[EXCLUSIONS_FIELD]) === JSON.stringify(exclusions)) return existing
|
|
82
|
-
if (exclusions.length === 0 && existing.length === 0) return existing
|
|
83
102
|
|
|
84
|
-
|
|
103
|
+
return { ...safe_chain, npm: { ...npm, [EXCLUSIONS_FIELD]: exclusions } }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function has_nothing_to_write(
|
|
107
|
+
existing: string,
|
|
108
|
+
exclusions: ReadonlyArray<string>,
|
|
109
|
+
npmrc: string,
|
|
110
|
+
): boolean {
|
|
111
|
+
return existing.length === 0 && exclusions.length === 0 && package_age_hours(npmrc) === undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function read_exclusions(workspace: string): ReadonlyArray<string> {
|
|
115
|
+
return workspace_schema.parse(yaml_document.parse_yaml(workspace)).minimumReleaseAgeExclude ?? []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function merge_project_config(existing: string, workspace: string, npmrc = ''): string {
|
|
119
|
+
const exclusions = read_exclusions(workspace)
|
|
120
|
+
const aikido = yaml_document.parse_yaml(existing)
|
|
121
|
+
const safe_chain = read_mapping(aikido['safe-chain'], 'safe-chain')
|
|
122
|
+
const merged = merge_age(merge_exclusions(safe_chain, exclusions), npmrc)
|
|
123
|
+
if (JSON.stringify(merged) === JSON.stringify(safe_chain)) return existing
|
|
124
|
+
if (has_nothing_to_write(existing, exclusions, npmrc)) return existing
|
|
85
125
|
|
|
86
126
|
return replace_safe_chain_block(existing, merged)
|
|
87
127
|
}
|
|
88
128
|
|
|
129
|
+
function read_optional(file_path: string): string {
|
|
130
|
+
return existsSync(file_path) ? readFileSync(file_path, 'utf8') : ''
|
|
131
|
+
}
|
|
132
|
+
|
|
89
133
|
function sync_project_config(root: string): boolean {
|
|
90
134
|
const workspace_path = path.join(root, WORKSPACE_FILE)
|
|
91
135
|
if (!existsSync(workspace_path)) return false
|
|
92
136
|
const config_path = path.join(root, PROJECT_CONFIG_FILE)
|
|
93
|
-
const existing =
|
|
137
|
+
const existing = read_optional(config_path)
|
|
94
138
|
const workspace = readFileSync(workspace_path, 'utf8')
|
|
95
|
-
const
|
|
139
|
+
const npmrc = read_optional(path.join(root, NPMRC_FILE))
|
|
140
|
+
const merged = merge_project_config(existing, workspace, npmrc)
|
|
96
141
|
if (merged === existing) return false
|
|
97
142
|
writeFileSync(config_path, merged)
|
|
98
143
|
|
package/scripts/sync/sync.ts
CHANGED
|
@@ -371,7 +371,6 @@ function report_repository_settings(name_with_owner: string | undefined): void {
|
|
|
371
371
|
|
|
372
372
|
function sync_project_artifacts(is_force: boolean): void {
|
|
373
373
|
sync_ai_copy_all(is_force)
|
|
374
|
-
project_config.sync_project_config(PROJECT_ROOT)
|
|
375
374
|
sync_prettier_config(path.join(PROJECT_ROOT, 'prettier.config.js'))
|
|
376
375
|
sync_playwright_config(path.join(PROJECT_ROOT, 'playwright.config.ts'))
|
|
377
376
|
sync_deploy_vps(path.join(PROJECT_ROOT, '.github/workflows/deploy-vps.yml'))
|
|
@@ -382,6 +381,9 @@ function sync_project_artifacts(is_force: boolean): void {
|
|
|
382
381
|
|
|
383
382
|
sync_sonar_with_template(name_with_owner, is_force)
|
|
384
383
|
sync_config_files()
|
|
384
|
+
// After `sync_config_files`, which may add the `.npmrc` window `.aikido`'s age is derived from
|
|
385
|
+
// (joshuafolkken/kit#2743).
|
|
386
|
+
project_config.sync_project_config(PROJECT_ROOT)
|
|
385
387
|
sync_package_json_migrations(path.join(PROJECT_ROOT, PACKAGE_JSON))
|
|
386
388
|
report_repository_settings(name_with_owner)
|
|
387
389
|
}
|
|
@@ -19,12 +19,18 @@ const NPMRC_PATH = '.npmrc'
|
|
|
19
19
|
const release_times_schema = z.record(z.string(), z.string())
|
|
20
20
|
const MS_PER_MINUTE = 60_000
|
|
21
21
|
|
|
22
|
+
// The window `.npmrc` content declares, or nothing when it does not declare one — an explicit
|
|
23
|
+
// `minimum-release-age=0` stays 0 rather than collapsing into "not declared".
|
|
24
|
+
function parse_declared_minimum_release_age(npmrc_content: string): number | undefined {
|
|
25
|
+
const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1]
|
|
26
|
+
|
|
27
|
+
return raw === undefined ? undefined : Number(raw)
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
// The quarantine window in minutes from `.npmrc`; a missing or malformed entry means no
|
|
23
31
|
// quarantine, matching pnpm's own default for the setting.
|
|
24
32
|
function parse_minimum_release_age(npmrc_content: string): number {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return raw === undefined ? NO_QUARANTINE_MINUTES : Number(raw)
|
|
33
|
+
return parse_declared_minimum_release_age(npmrc_content) ?? NO_QUARANTINE_MINUTES
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
// Stable releases only, on the requested major, published at or before the cutoff. The
|
|
@@ -82,9 +88,7 @@ function select_aged_version(
|
|
|
82
88
|
// `minimum-release-age=0` opt-out stop the walk instead of falling through to an ancestor's policy.
|
|
83
89
|
function read_declared_minimum_release_age(npmrc_path: string): number | undefined {
|
|
84
90
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return raw === undefined ? undefined : Number(raw)
|
|
91
|
+
return parse_declared_minimum_release_age(readFileSync(npmrc_path, 'utf8'))
|
|
88
92
|
} catch {
|
|
89
93
|
return undefined
|
|
90
94
|
}
|
|
@@ -138,6 +142,7 @@ function read_nearest_minimum_release_age(start: string, boundary?: string): num
|
|
|
138
142
|
const release_age = {
|
|
139
143
|
release_times_schema,
|
|
140
144
|
parse_minimum_release_age,
|
|
145
|
+
parse_declared_minimum_release_age,
|
|
141
146
|
read_minimum_release_age,
|
|
142
147
|
read_nearest_minimum_release_age,
|
|
143
148
|
select_aged_version,
|