halfcycle 0.4.1 → 0.4.3

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/dist/cli.js CHANGED
@@ -1566,6 +1566,14 @@ function readRootCommit(targetRepoRoot) {
1566
1566
  function isGitWorkTree(targetRepoRoot) {
1567
1567
  return git(targetRepoRoot, ["rev-parse", "--is-inside-work-tree"]) === "true";
1568
1568
  }
1569
+ function gitIsInstalled() {
1570
+ try {
1571
+ execFileSync("git", ["--version"], { stdio: "ignore", env: gitEnvForNamedRepo() });
1572
+ return true;
1573
+ } catch {
1574
+ return false;
1575
+ }
1576
+ }
1569
1577
  function readRemote(targetRepoRoot) {
1570
1578
  return git(targetRepoRoot, ["remote", "get-url", "origin"]);
1571
1579
  }
@@ -1934,8 +1942,20 @@ function projectPageUrl(engagementId) {
1934
1942
  }
1935
1943
 
1936
1944
  // dist/merge-settings.js
1945
+ function isHalfcycleCommand(hook, halfcycleCommands) {
1946
+ if (typeof hook !== "object" || hook === null)
1947
+ return false;
1948
+ const command = hook.command;
1949
+ return typeof command === "string" && halfcycleCommands.has(command);
1950
+ }
1937
1951
  function isHalfcycleEntry(entry, halfcycleCommands) {
1938
- return entry.hooks.some((h) => halfcycleCommands.has(h.command));
1952
+ return entry.hooks.some((h) => isHalfcycleCommand(h, halfcycleCommands));
1953
+ }
1954
+ function withoutHalfcycleCommands(entry, halfcycleCommands) {
1955
+ if (!isHalfcycleEntry(entry, halfcycleCommands))
1956
+ return entry;
1957
+ const remaining = entry.hooks.filter((h) => !isHalfcycleCommand(h, halfcycleCommands));
1958
+ return remaining.length > 0 ? { ...entry, hooks: remaining } : null;
1939
1959
  }
1940
1960
  function mergeSettings(existing, generated) {
1941
1961
  const merged = { ...existing };
@@ -1954,7 +1974,7 @@ function mergeSettings(existing, generated) {
1954
1974
  const mergedHooks = {};
1955
1975
  const events = /* @__PURE__ */ new Set([...Object.keys(existingHooks), ...Object.keys(generatedHooks)]);
1956
1976
  for (const event of events) {
1957
- const developerEntries = (existingHooks[event] ?? []).filter((entry) => !isHalfcycleEntry(entry, halfcycleCommands));
1977
+ const developerEntries = (existingHooks[event] ?? []).flatMap((entry) => withoutHalfcycleCommands(entry, halfcycleCommands) ?? []);
1958
1978
  const halfcycleEntries = generatedHooks[event] ?? [];
1959
1979
  mergedHooks[event] = [...developerEntries, ...halfcycleEntries];
1960
1980
  }
@@ -2122,6 +2142,7 @@ function closedWriteSet() {
2122
2142
  ownedDirs,
2123
2143
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
2124
2144
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
2145
+ settingsSchema: SETTINGS_SCHEMA,
2125
2146
  gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
2126
2147
  gitattributesLines: /* @__PURE__ */ new Set([
2127
2148
  GITATTRIBUTES_HEADER,
@@ -2143,6 +2164,11 @@ function sha256OfWhatThisVersionWrites(commands) {
2143
2164
  fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
2144
2165
  return out;
2145
2166
  }
2167
+ function bytesAreOurs(path, bytes, recorded, writeSet) {
2168
+ const hash = sha256Hex(bytes);
2169
+ const recordedHash = recorded !== void 0 && "sha256" in recorded ? recorded.sha256 : void 0;
2170
+ return hash === recordedHash || hash === writeSet.generatedSha256.get(path);
2171
+ }
2146
2172
  function readPreviousManifest(targetRepo) {
2147
2173
  try {
2148
2174
  return parseManifest(readFileSync5(join5(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
@@ -2291,7 +2317,7 @@ function readPluginManifest() {
2291
2317
  const raw = readFileSync5(manifestPath, "utf-8");
2292
2318
  return JSON.parse(raw);
2293
2319
  }
2294
- function copyManifestCommands(manifest, targetRepo, report, draft) {
2320
+ function copyManifestCommands(manifest, targetRepo, report, draft, writeSet) {
2295
2321
  for (const cmd of manifest.commands ?? []) {
2296
2322
  const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
2297
2323
  if (!existsSync3(srcFile)) {
@@ -2300,8 +2326,13 @@ function copyManifestCommands(manifest, targetRepo, report, draft) {
2300
2326
  const rel = commandStubRel(cmd.name);
2301
2327
  const destFile = join5(targetRepo, rel);
2302
2328
  const content = readFileSync5(srcFile, "utf-8");
2303
- const outcome = writeCollisionSafe(destFile, targetRepo, content);
2329
+ const recorded = draft.files.get(rel);
2330
+ const { outcome, replacedExisting } = writeCollisionSafe(destFile, targetRepo, content, (current) => bytesAreOurs(rel, current, recorded, writeSet));
2304
2331
  record(report, outcome, rel);
2332
+ if (replacedExisting)
2333
+ report.replacedPaths.push(rel);
2334
+ if (outcome === "collided" && recorded !== void 0)
2335
+ report.editedCollidedPaths.push(rel);
2305
2336
  noteFile(draft, targetRepo, rel, outcome);
2306
2337
  }
2307
2338
  }
@@ -2316,18 +2347,23 @@ function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths)
2316
2347
  writeFileSync5(targetAbsPath, content, "utf-8");
2317
2348
  writtenPaths.push(rel);
2318
2349
  }
2319
- function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
2350
+ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content, isOurs) {
2320
2351
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
2321
2352
  if (!isAllowlisted(rel)) {
2322
2353
  throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
2323
2354
  }
2355
+ let replacedExisting = false;
2324
2356
  if (existsSync3(targetAbsPath)) {
2325
- const current = readFileSync5(targetAbsPath, "utf-8");
2326
- return current === content ? "skipped" : "collided";
2357
+ const current = readFileSync5(targetAbsPath);
2358
+ if (current.toString("utf-8") === content)
2359
+ return { outcome: "skipped", replacedExisting: false };
2360
+ if (!isOurs(current))
2361
+ return { outcome: "collided", replacedExisting: false };
2362
+ replacedExisting = true;
2327
2363
  }
2328
2364
  mkdirSync5(dirname3(targetAbsPath), { recursive: true });
2329
2365
  writeFileSync5(targetAbsPath, content, "utf-8");
2330
- return "written";
2366
+ return { outcome: "written", replacedExisting };
2331
2367
  }
2332
2368
  var INSTALLER_OWNED_DIR = ".halfcycle/";
2333
2369
  function writeOwned(targetAbsPath, targetRepoRoot, content) {
@@ -2420,6 +2456,7 @@ function settingsParseRefusal(targetRepo, err) {
2420
2456
  }
2421
2457
  return `${file} could not be read as JSON${where} \u2014 fix it and run "npx halfcycle" again.`;
2422
2458
  }
2459
+ var SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
2423
2460
  function generateSettingsJson() {
2424
2461
  const hooks = {};
2425
2462
  for (const { event, matcher, timeout, command } of HALFCYCLE_HOOK_WIRING) {
@@ -2429,7 +2466,7 @@ function generateSettingsJson() {
2429
2466
  });
2430
2467
  }
2431
2468
  const settings = {
2432
- $schema: "https://json.schemastore.org/claude-code-settings.json",
2469
+ $schema: SETTINGS_SCHEMA,
2433
2470
  permissions: {
2434
2471
  // deny ONLY. See DENY_PATTERNS — an allow list evaporates under bypass mode
2435
2472
  // and is the machine owner's grant to make, not ours.
@@ -2729,19 +2766,27 @@ function adoptBlockUnderHeader(before, header, claimable) {
2729
2766
  let underHeader = false;
2730
2767
  const lines = before.split("\n");
2731
2768
  for (const [i, line] of lines.entries()) {
2732
- if (line === header) {
2733
- if (i > 0 && lines[i - 1] === "")
2734
- claimed.push("");
2769
+ const key = lineKey(line);
2770
+ if (key === header) {
2771
+ if (i > 0 && lineKey(lines[i - 1]) === "")
2772
+ claimed.push(lines[i - 1]);
2735
2773
  claimed.push(line);
2736
2774
  underHeader = true;
2737
- } else if (line.trim() === "") {
2775
+ } else if (key.trim() === "") {
2738
2776
  underHeader = false;
2739
- } else if (underHeader && claimable.has(line)) {
2777
+ } else if (underHeader && claimable.has(key)) {
2740
2778
  claimed.push(line);
2741
2779
  }
2742
2780
  }
2743
2781
  return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
2744
2782
  }
2783
+ function lineKey(line) {
2784
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
2785
+ }
2786
+ function lineEndingOf(text) {
2787
+ const at = text.indexOf("\n");
2788
+ return at > 0 && text[at - 1] === "\r" ? "\r\n" : "\n";
2789
+ }
2745
2790
  function gitignoreCovers(content) {
2746
2791
  const lines = new Set(content.split("\n").map((l) => l.trim()));
2747
2792
  return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
@@ -2760,9 +2805,8 @@ function reconcileGitignore(targetRepo) {
2760
2805
  return "skipped";
2761
2806
  const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
2762
2807
  const toAdd = REQUIRED_GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
2763
- const suffix = (existing.endsWith("\n") ? "" : "\n") + `
2764
- ${header}
2765
- ` + toAdd.join("\n") + "\n";
2808
+ const eol = lineEndingOf(existing);
2809
+ const suffix = (existing.endsWith("\n") ? "" : eol) + eol + [header, ...toAdd].join(eol) + eol;
2766
2810
  writeFileSync5(gitignorePath, existing + suffix, "utf-8");
2767
2811
  return "written";
2768
2812
  } catch {
@@ -2797,8 +2841,9 @@ function reconcileGitattributes(targetRepo) {
2797
2841
  const toAdd = rules.filter((rule) => !present.has(rule));
2798
2842
  if (toAdd.length === 0)
2799
2843
  return "skipped";
2800
- const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2801
- const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
2844
+ const eol = lineEndingOf(existing);
2845
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? eol : eol + eol;
2846
+ const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join(eol) + eol;
2802
2847
  writeAllowlisted(path, targetRepo, existing + suffix, routed);
2803
2848
  return "written";
2804
2849
  } catch {
@@ -2818,9 +2863,9 @@ function previousPinForEngagement(targetRepoRoot, engagementId) {
2818
2863
  function nonEmpty(value) {
2819
2864
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
2820
2865
  }
2821
- function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, methodVersion, writtenPaths) {
2866
+ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, createdByAccountId, methodVersion, writtenPaths) {
2822
2867
  const previous = previousPinForEngagement(targetRepoRoot, engagementId);
2823
- const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
2868
+ const carried = pinCreatedByAccountId(previous) ?? nonEmpty(createdByAccountId);
2824
2869
  const carriedMethodVersion = nonEmpty(methodVersion) ?? nonEmpty(previous?.methodVersion);
2825
2870
  const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
2826
2871
  const projectPage = projectPageUrl(engagementId);
@@ -2829,7 +2874,7 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
2829
2874
  engagementId,
2830
2875
  engagementType,
2831
2876
  installedAt,
2832
- ...carried !== void 0 ? { accountId: carried } : {},
2877
+ ...carried !== void 0 ? { createdByAccountId: carried } : {},
2833
2878
  ...carriedMethodVersion !== void 0 ? { methodVersion: carriedMethodVersion } : {},
2834
2879
  ...projectPage !== void 0 ? { projectPage } : {}
2835
2880
  };
@@ -2846,6 +2891,9 @@ function writeCrewRoster(targetRepoRoot, report) {
2846
2891
  const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
2847
2892
  return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
2848
2893
  }
2894
+ function pinCreatedByAccountId(pin) {
2895
+ return nonEmpty(pin?.createdByAccountId) ?? nonEmpty(pin?.accountId);
2896
+ }
2849
2897
  function readBundlePin(targetRepoRoot) {
2850
2898
  const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
2851
2899
  if (!existsSync3(pinPath))
@@ -2987,7 +3035,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
2987
3035
  }
2988
3036
  }
2989
3037
  async function install(options) {
2990
- const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
3038
+ const { targetRepo, engagementId, engagementType, credential, home, createdByAccountId } = options;
2991
3039
  assertEngagementId(engagementId);
2992
3040
  if (!existsSync3(targetRepo)) {
2993
3041
  throw new Error(`[halfcycle] Target repo does not exist: ${targetRepo}`);
@@ -2998,12 +3046,13 @@ async function install(options) {
2998
3046
  skippedPaths: [],
2999
3047
  mergedPaths: [],
3000
3048
  collidedPaths: [],
3049
+ editedCollidedPaths: [],
3001
3050
  replacedPaths: []
3002
3051
  };
3003
3052
  const writeSet = closedWriteSet();
3004
3053
  const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
3005
3054
  const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
3006
- copyManifestCommands(manifest, targetRepo, report, draft);
3055
+ copyManifestCommands(manifest, targetRepo, report, draft, writeSet);
3007
3056
  const vendoredBinSrc = resolveVendoredBinary();
3008
3057
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
3009
3058
  const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
@@ -3125,7 +3174,7 @@ async function install(options) {
3125
3174
  case "absent":
3126
3175
  break;
3127
3176
  }
3128
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, credential?.methodVersion, report.writtenPaths);
3177
+ writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, createdByAccountId, credential?.methodVersion, report.writtenPaths);
3129
3178
  noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
3130
3179
  noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
3131
3180
  const scanResult = runBootstrapScan(targetRepo);
@@ -3145,6 +3194,7 @@ async function install(options) {
3145
3194
  skippedPaths: report.skippedPaths,
3146
3195
  mergedPaths: report.mergedPaths,
3147
3196
  collidedPaths: report.collidedPaths,
3197
+ editedCollidedPaths: report.editedCollidedPaths,
3148
3198
  replacedPaths: report.replacedPaths,
3149
3199
  bootstrapScanRan: scanResult.ran,
3150
3200
  legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null,
@@ -3329,8 +3379,8 @@ var SignInRefused = class extends Error {
3329
3379
  }
3330
3380
  };
3331
3381
  var SIGN_IN_TIMED_OUT = "timed-out";
3332
- var SIGN_IN_TIMEOUT_MESSAGE = "Sign-in timed out: the Halfcycle service stopped answering while this terminal was waiting for the browser. Nothing was created. Check your connection and run the command again.";
3333
- var SIGN_IN_NON_INTERACTIVE_MESSAGE = "Halfcycle needs a signed-in account and this looks like a non-interactive run (CI is set), so there is no browser to sign in with. Set HALFCYCLE_TOKEN in the environment to a Halfcycle credential and run this again. Nothing was created.";
3382
+ var SIGN_IN_TIMEOUT_MESSAGE = "Sign-in timed out: the Halfcycle service stopped answering while this terminal was waiting for the browser. Check your connection and run the command again.";
3383
+ var SIGN_IN_NON_INTERACTIVE_MESSAGE = "Halfcycle needs a signed-in account and this looks like a non-interactive run (CI is set), so there is no browser to sign in with. Set HALFCYCLE_TOKEN in the environment to a Halfcycle credential and run this again.";
3334
3384
  var DEADLINE_SLACK_INTERVALS = 1;
3335
3385
  var STILL_WAITING_MS = 15e3;
3336
3386
  var DEFAULT_SIGN_IN_REASON = "set up this project";
@@ -3425,6 +3475,9 @@ function isNonInteractive(env = process.env) {
3425
3475
  const ci = (env["CI"] ?? "").trim().toLowerCase();
3426
3476
  return ci !== "" && ci !== "0" && ci !== "false";
3427
3477
  }
3478
+ function isInteractiveRun(env = process.env) {
3479
+ return process.stdin.isTTY === true && !isNonInteractive(env);
3480
+ }
3428
3481
  async function startDeviceAuth(serviceUrl) {
3429
3482
  const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/start`;
3430
3483
  let res;
@@ -3435,16 +3488,16 @@ async function startDeviceAuth(serviceUrl) {
3435
3488
  body: "{}"
3436
3489
  });
3437
3490
  } catch (err) {
3438
- throw new SignInRefused(SIGN_IN_TIMED_OUT, `Could not reach the Halfcycle service at ${url} to start a sign-in: ${err instanceof Error ? err.message : String(err)}. Nothing was created.`);
3491
+ throw new SignInRefused(SIGN_IN_TIMED_OUT, `Could not reach the Halfcycle service at ${url} to start a sign-in: ${err instanceof Error ? err.message : String(err)}.`);
3439
3492
  }
3440
3493
  const body = await res.json().catch(() => null);
3441
3494
  if (res.status !== 201) {
3442
3495
  const refusal = tryRefusal(body);
3443
- throw new SignInRefused(refusal?.status ?? String(res.status), refusal?.message ?? `Sign-in could not be started: ${url} answered ${res.status}. Nothing was created.`);
3496
+ throw new SignInRefused(refusal?.status ?? String(res.status), refusal?.message ?? `Sign-in could not be started: ${url} answered ${res.status}.`);
3444
3497
  }
3445
3498
  const parsed = deviceAuthStartResponseSchema.safeParse(body);
3446
3499
  if (!parsed.success) {
3447
- throw new SignInRefused(SIGN_IN_TIMED_OUT, `The Halfcycle service at ${url} answered a sign-in start this client cannot read. Either that service is older than this installer, or the address is not a Halfcycle service. Nothing was created.`);
3500
+ throw new SignInRefused(SIGN_IN_TIMED_OUT, `The Halfcycle service at ${url} answered a sign-in start this client cannot read. Either that service is older than this installer, or the address is not a Halfcycle service.`);
3448
3501
  }
3449
3502
  return parsed.data;
3450
3503
  }
@@ -3582,7 +3635,7 @@ async function signIn(serviceUrl, deps = {}) {
3582
3635
  const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
3583
3636
  const attempt = await openBrowser(url, env);
3584
3637
  if (attempt.opened) {
3585
- write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. If a browser did not open, go to: ${url}. This terminal is waiting.
3638
+ write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. If a browser did not open, go to: ${started.verificationUrl}. This terminal is waiting.
3586
3639
  `);
3587
3640
  const startedAt = now();
3588
3641
  const redirect = bind.waitForCallback({
@@ -3709,7 +3762,7 @@ async function createEngagementForProject(baseUrl, projectId, credential, derive
3709
3762
  const body = { derivedName };
3710
3763
  return requestEngagementValues("PUT", `${baseUrl.replace(/\/+$/, "")}/engagements/by-project/${encodeURIComponent(projectId)}`, body, credential, {
3711
3764
  action: "Project setup",
3712
- nothingHappened: "This folder was not set up",
3765
+ nothingHappened: "Halfcycle did not finish setting up this folder",
3713
3766
  route: "PUT /engagements/by-project/:projectId",
3714
3767
  // FALSE on this call, and only this one: the folder's id is already on
3715
3768
  // disk by the time this request is sent. See CallShape.alsoNothingWritten.
@@ -4147,21 +4200,15 @@ async function askAccount(serviceUrl, credential, fetchImpl = fetch) {
4147
4200
  async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
4148
4201
  return (await askAccount(serviceUrl, credential, fetchImpl)).identity;
4149
4202
  }
4150
- function accountLabel(identity, storedAccountId, verbose2 = false, confirmed = true) {
4203
+ function accountLabel(identity, storedAccountId, verbose2 = false) {
4151
4204
  const accountId = identity?.accountId ?? storedAccountId;
4152
4205
  if (identity?.email !== void 0 && identity.email.trim() !== "") {
4153
4206
  return verbose2 && accountId !== void 0 ? `${identity.email} (account ${accountId})` : identity.email;
4154
4207
  }
4155
- if (!confirmed) {
4156
- return "the account this project is already set up with \u2014 this run could not reach the Halfcycle service to confirm who that is";
4157
- }
4158
4208
  if (accountId !== void 0)
4159
4209
  return `account ${accountId} (no email on record for it)`;
4160
4210
  return "the account this credential belongs to, which this service did not name";
4161
4211
  }
4162
- function isInteractiveRun(env = process.env) {
4163
- return process.stdin.isTTY === true && !isNonInteractive(env);
4164
- }
4165
4212
  function onlyDate(iso) {
4166
4213
  if (iso === void 0)
4167
4214
  return void 0;
@@ -4210,7 +4257,7 @@ function armScript(credential, act, label, plainLabel, home) {
4210
4257
  switch (credential.source) {
4211
4258
  case "browser-handoff":
4212
4259
  return {
4213
- preamble: `[halfcycle] Signed in as ${label}. This install will ${actClause(act)}.
4260
+ preamble: `[halfcycle] This install will ${actClause(act)}.
4214
4261
  `,
4215
4262
  asks: false,
4216
4263
  noTerminalNote: "",
@@ -4232,7 +4279,7 @@ function armScript(credential, act, label, plainLabel, home) {
4232
4279
  noTerminalNote: "",
4233
4280
  switchOpening: `[halfcycle] Signing you in through your browser \u2014 this install will use that account instead.
4234
4281
  `,
4235
- stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. HALFCYCLE_TOKEN in this environment is the credential for ${plainLabel}, and it takes precedence over any saved sign-in \u2014 so unset it, or set it to your own account's credential, before running this again.`,
4282
+ stopRemedy: `Stopped before anything was set up. HALFCYCLE_TOKEN in this environment is the credential for ${plainLabel}, and it takes precedence over any saved sign-in \u2014 so unset it, or set it to your own account's credential, before running this again.`,
4236
4283
  // The switch is real for THIS run (the new credential is what gets used) and it
4237
4284
  // does not persist: `obtainCredential` reads the variable first, so the next run
4238
4285
  // is the same variable again. Said here rather than discovered tomorrow.
@@ -4254,11 +4301,11 @@ function armScript(credential, act, label, plainLabel, home) {
4254
4301
  // this is printed in), and where to look for the answer once this output is gone.
4255
4302
  noTerminalNote: `[halfcycle] Nothing here to ask at (no terminal), so this is continuing as that account.
4256
4303
  [halfcycle] To run as a different one, set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)} and run this again with a terminal to sign in.
4257
- [halfcycle] When this install finishes, which account it used is recorded in .halfcycle/bundle.json, so it can be checked after this output is gone.
4304
+ [halfcycle] .halfcycle/bundle.json records the account that created this project, so that can be checked after this output is gone.
4258
4305
  `,
4259
4306
  switchOpening: `[halfcycle] Forgetting this machine's saved sign-in and signing you in through your browser.
4260
4307
  `,
4261
- stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. This machine's saved sign-in is for ${plainLabel}. Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
4308
+ stopRemedy: `Stopped before anything was set up. This machine's saved sign-in is for ${plainLabel}. Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
4262
4309
  afterSwitch: ""
4263
4310
  };
4264
4311
  }
@@ -4299,7 +4346,7 @@ async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
4299
4346
  write(script.switchOpening);
4300
4347
  const replacement = await switchAccount(serviceUrl, deps);
4301
4348
  const replacementIdentity = await describeAccount(serviceUrl, replacement.credential, deps.fetchImpl);
4302
- write(`[halfcycle] Signed in as ${ink("keyBold", accountLabel(replacementIdentity, replacement.accountId, deps.verbose === true), colour)}. This install will ${actClause(act)}.
4349
+ write(`[halfcycle] Signed in. This install will ${actClause(act)}.
4303
4350
  `);
4304
4351
  if (script.afterSwitch !== "")
4305
4352
  write(script.afterSwitch);
@@ -4415,6 +4462,7 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
4415
4462
  import { spawnSync } from "node:child_process";
4416
4463
  import { existsSync as existsSync4, readFileSync as readFileSync6, realpathSync } from "node:fs";
4417
4464
  import { delimiter, dirname as dirname4, join as join6 } from "node:path";
4465
+ var KEPT_FOLDER_ID_LINE = 'Nothing on the Halfcycle service needs undoing, and this folder keeps its project id, so running "npx halfcycle" again picks up where it left off.';
4418
4466
  var PreCreateRefused = class extends Error {
4419
4467
  constructor(message) {
4420
4468
  super(message);
@@ -4497,18 +4545,22 @@ function claimFolderId(targetRepo) {
4497
4545
  } catch {
4498
4546
  throw new PreCreateRefused(`cannot write to ${targetRepo} \u2014 run "npx halfcycle" in a folder you can write to.`);
4499
4547
  }
4500
- const file = join6(targetRepo, PROJECT_IDENTITY_REL);
4548
+ const folderId = readFolderId(targetRepo);
4549
+ if (folderId === void 0) {
4550
+ throw new PreCreateRefused(`${join6(targetRepo, PROJECT_IDENTITY_REL)} could not be read as a Halfcycle project file \u2014 delete the .halfcycle folder in ${targetRepo} and run "npx halfcycle" again to start a new project here.`);
4551
+ }
4552
+ return folderId;
4553
+ }
4554
+ function readFolderId(targetRepo) {
4501
4555
  let folderId;
4502
4556
  try {
4503
- folderId = JSON.parse(readFileSync6(file, "utf-8"))?.projectId;
4557
+ const text = readFileSync6(join6(targetRepo, PROJECT_IDENTITY_REL), "utf-8");
4558
+ folderId = JSON.parse(text)?.projectId;
4504
4559
  } catch {
4505
- folderId = void 0;
4560
+ return void 0;
4506
4561
  }
4507
4562
  const parsed = projectIdSchema.safeParse(folderId);
4508
- if (!parsed.success) {
4509
- throw new PreCreateRefused(`${file} could not be read as a Halfcycle project file \u2014 delete the .halfcycle folder in ${targetRepo} and run "npx halfcycle" again to start a new project here.`);
4510
- }
4511
- return parsed.data;
4563
+ return parsed.success ? parsed.data : void 0;
4512
4564
  }
4513
4565
  function checkSettingsParse(targetRepo) {
4514
4566
  try {
@@ -4603,9 +4655,9 @@ var UPGRADE_COMMANDS = {
4603
4655
  };
4604
4656
  var INSTALL_METHOD_RULES = [
4605
4657
  { method: "winget", pattern: /[\\/]WinGet[\\/]/i },
4606
- { method: "npm", pattern: /node_modules[\\/]/i },
4658
+ { method: "npm", pattern: /node_modules[\\/]|[\\/]npm[\\/]claude(\.cmd|\.exe)?$/i },
4607
4659
  { method: "brew", pattern: /[\\/](Cellar|Caskroom|linuxbrew|homebrew)[\\/]/i },
4608
- { method: "native", pattern: /[\\/]\.local[\\/]share[\\/]claude(-code)?[\\/]/i }
4660
+ { method: "native", pattern: /[\\/]\.local[\\/](share[\\/]claude(-code)?|bin)[\\/]/i }
4609
4661
  ];
4610
4662
  function probeClaudeCodeInstallMethod(deps = {}) {
4611
4663
  const env = deps.env ?? process.env;
@@ -5810,9 +5862,6 @@ function ourHookEntries(value, commands) {
5810
5862
  }
5811
5863
  return found;
5812
5864
  }
5813
- function isOurHook(hook, commands) {
5814
- return isPlainObject2(hook) && typeof hook["command"] === "string" && commands.has(hook["command"]);
5815
- }
5816
5865
  function ourMcpServers(value) {
5817
5866
  const servers = value["mcpServers"];
5818
5867
  return isPlainObject2(servers) && MCP_SERVER_KEY in servers ? servers : null;
@@ -5841,9 +5890,9 @@ function unmergeSettings(value, rec, writeSet) {
5841
5890
  continue;
5842
5891
  }
5843
5892
  touched = true;
5844
- const remaining = entry.hooks.filter((h) => !isOurHook(h, commands));
5845
- if (remaining.length > 0)
5846
- next.push({ ...entry, hooks: remaining });
5893
+ const remaining = withoutHalfcycleCommands(entry, commands);
5894
+ if (remaining !== null)
5895
+ next.push(remaining);
5847
5896
  }
5848
5897
  if (touched)
5849
5898
  changed = true;
@@ -5882,16 +5931,12 @@ function unmergeSettings(value, rec, writeSet) {
5882
5931
  changed = true;
5883
5932
  }
5884
5933
  }
5885
- if ("schemaBefore" in rec) {
5886
- if (rec.schemaBefore === null) {
5887
- if ("$schema" in value) {
5888
- delete value["$schema"];
5889
- changed = true;
5890
- }
5891
- } else if (value["$schema"] !== rec.schemaBefore) {
5934
+ if ("schemaBefore" in rec && value["$schema"] === writeSet.settingsSchema) {
5935
+ if (rec.schemaBefore === null)
5936
+ delete value["$schema"];
5937
+ else
5892
5938
  value["$schema"] = rec.schemaBefore;
5893
- changed = true;
5894
- }
5939
+ changed = true;
5895
5940
  }
5896
5941
  return { changed, refused };
5897
5942
  }
@@ -5931,36 +5976,70 @@ function gitattributesRules(writeSet) {
5931
5976
  function trimAppendedBlocks(text, rec, rules) {
5932
5977
  const keptLines = [];
5933
5978
  const foreignLines = [];
5979
+ const eol = lineEndingOf(text);
5934
5980
  let current = text;
5935
5981
  for (const block of [...rec.appended].reverse()) {
5936
- const lines = block.split("\n").filter((line) => line !== "");
5982
+ const lines = block.split("\n").map(lineKey).filter((line) => line !== "");
5937
5983
  const foreign = lines.filter((line) => !rules.ourLines.has(line));
5938
5984
  foreignLines.push(...foreign);
5939
5985
  const ours = lines.filter((line) => rules.ourLines.has(line));
5940
5986
  const entries = ours.filter((line) => line !== rules.header);
5941
5987
  const kept = entries.filter((line) => rules.keepLine(line));
5942
5988
  keptLines.push(...kept);
5943
- if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
5944
- const at = current.lastIndexOf(block);
5945
- const head = current.slice(0, at);
5946
- const tail = current.slice(at + block.length);
5947
- current = head + (head !== "" && tail !== "" && !head.endsWith("\n") ? "\n" : "") + tail;
5989
+ const found = kept.length === 0 && foreign.length === 0 ? lastOccurrence(current, block) : null;
5990
+ if (found !== null) {
5991
+ const head = current.slice(0, found.start);
5992
+ const tail = current.slice(found.end);
5993
+ current = head + (head !== "" && tail !== "" && !head.endsWith("\n") ? eol : "") + tail;
5948
5994
  continue;
5949
5995
  }
5950
- const separated = block.startsWith("\n") || block.includes("\n\n");
5996
+ const flat = withoutLineEndCr(block).text;
5997
+ const separated = flat.startsWith("\n") || flat.includes("\n\n");
5951
5998
  const removable = ours.filter((line) => line === rules.header ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
5952
5999
  for (const line of removable) {
5953
6000
  const fileLines = current.split("\n");
5954
- const at = fileLines.lastIndexOf(line);
6001
+ const keys = fileLines.map(lineKey);
6002
+ const at = keys.lastIndexOf(line);
5955
6003
  if (at < 0)
5956
6004
  continue;
5957
- const withSeparator = line === rules.header && separated && at > 0 && fileLines[at - 1] === "";
6005
+ const withSeparator = line === rules.header && separated && at > 0 && keys[at - 1] === "";
5958
6006
  fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
5959
6007
  current = fileLines.join("\n");
5960
6008
  }
5961
6009
  }
5962
6010
  return { text: current, keptLines, foreignLines };
5963
6011
  }
6012
+ function withoutLineEndCr(text) {
6013
+ let out = "";
6014
+ const at = [];
6015
+ let offset = 0;
6016
+ const lines = text.split("\n");
6017
+ for (const [i, line] of lines.entries()) {
6018
+ const key = lineKey(line);
6019
+ for (let j = 0; j < key.length; j++)
6020
+ at.push(offset + j);
6021
+ out += key;
6022
+ offset += line.length;
6023
+ if (i < lines.length - 1) {
6024
+ out += "\n";
6025
+ at.push(offset);
6026
+ offset += 1;
6027
+ }
6028
+ }
6029
+ return { text: out, at };
6030
+ }
6031
+ function lastOccurrence(text, block) {
6032
+ const needle = withoutLineEndCr(block).text;
6033
+ if (needle === "")
6034
+ return null;
6035
+ const hay = withoutLineEndCr(text);
6036
+ const found = hay.text.lastIndexOf(needle);
6037
+ if (found < 0)
6038
+ return null;
6039
+ const first = hay.at[found];
6040
+ const start = first > 0 && text[first] === "\n" && text[first - 1] === "\r" ? first - 1 : first;
6041
+ return { start, end: hay.at[found + needle.length - 1] + 1 };
6042
+ }
5964
6043
  function coveredPathExists(targetRepo, line) {
5965
6044
  const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
5966
6045
  return path !== "" && lexists(join10(targetRepo, path));
@@ -6111,16 +6190,15 @@ function removeInstalledFile(targetRepo, path, entry, writeSet, lists, name) {
6111
6190
  return;
6112
6191
  }
6113
6192
  if (!writeSet.ownedDirs.has(path.slice(0, path.lastIndexOf("/")))) {
6114
- let hash;
6193
+ let bytes;
6115
6194
  try {
6116
- hash = sha256Hex(readFileSync9(abs));
6195
+ bytes = readFileSync9(abs);
6117
6196
  } catch {
6118
6197
  name(lists.failed, path, path);
6119
6198
  return;
6120
6199
  }
6121
- const recordedHash = entry !== void 0 && "sha256" in entry ? entry.sha256 : void 0;
6122
- if (hash !== recordedHash && hash !== writeSet.generatedSha256.get(path)) {
6123
- name(recordedHash !== void 0 ? lists.kept : lists.leftAlone, path, path);
6200
+ if (!bytesAreOurs(path, bytes, entry, writeSet)) {
6201
+ name(entry !== void 0 && "sha256" in entry ? lists.kept : lists.leftAlone, path, path);
6124
6202
  return;
6125
6203
  }
6126
6204
  }
@@ -6530,6 +6608,14 @@ ${USAGE}`);
6530
6608
  process.exit(1);
6531
6609
  return;
6532
6610
  }
6611
+ const onInstallSigint = () => {
6612
+ const kept = readFolderId(targetRepo) !== void 0 ? `${KEPT_FOLDER_ID_LINE}
6613
+ ` : "";
6614
+ process.stderr.write(`halfcycle: interrupted.
6615
+ ${kept}`);
6616
+ process.exit(1);
6617
+ };
6618
+ process.once("SIGINT", onInstallSigint);
6533
6619
  try {
6534
6620
  const controlOrigin = resolveControlOrigin(process.env);
6535
6621
  const serviceUrl = controlOrigin.origin;
@@ -6550,13 +6636,14 @@ ${USAGE}`);
6550
6636
  let credential;
6551
6637
  let notThisAccountMessage;
6552
6638
  let actingAccountId;
6639
+ let createdByAccountId;
6553
6640
  let actingIdentity;
6554
6641
  let foundLine = "";
6555
6642
  let reusedWithoutFreshAnswer = false;
6556
6643
  if (reusable !== void 0 && pinned !== null) {
6557
6644
  engagementId = pinned.engagementId;
6558
6645
  credential = reusable;
6559
- actingAccountId = readBundlePin(targetRepo)?.accountId;
6646
+ actingAccountId = heldAnswer?.identity?.accountId;
6560
6647
  foundLine = `already set up here \u2014 reusing engagement ${shortId(pinned.engagementId)}`;
6561
6648
  reusedWithoutFreshAnswer = true;
6562
6649
  if (verbose) {
@@ -6606,6 +6693,7 @@ ${USAGE}`);
6606
6693
  `);
6607
6694
  }
6608
6695
  } else {
6696
+ const hadUsableIdBeforeThisRun = readFolderId(targetRepo) !== void 0;
6609
6697
  const projectId = runPreCreateChecks(targetRepo);
6610
6698
  const created = await createOwnedEngagement(serviceUrl, targetRepo, projectId, { assumeYes, verbose });
6611
6699
  if (isProjectOwnedElsewhere(created)) {
@@ -6617,6 +6705,7 @@ ${PROJECT_RESET_LINE}
6617
6705
  }
6618
6706
  engagementId = created.engagementId;
6619
6707
  actingAccountId = created.actingAccountId;
6708
+ createdByAccountId = created.actingAccountId;
6620
6709
  actingIdentity = created.actingIdentity;
6621
6710
  credential = {
6622
6711
  serviceUrl,
@@ -6636,7 +6725,7 @@ ${PROJECT_RESET_LINE}
6636
6725
  consoleUrl: created.consoleUrl,
6637
6726
  methodVersion: created.methodVersion
6638
6727
  };
6639
- foundLine = `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
6728
+ foundLine = hadUsableIdBeforeThisRun ? `picking up this folder's project (${shortId(created.engagementId)})` : `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
6640
6729
  if (verbose) {
6641
6730
  process.stdout.write(`[halfcycle] Created engagement ${created.engagementId}
6642
6731
  `);
@@ -6647,7 +6736,7 @@ ${PROJECT_RESET_LINE}
6647
6736
  engagementId,
6648
6737
  engagementType: engagementTypeRaw,
6649
6738
  credential,
6650
- accountId: actingAccountId
6739
+ createdByAccountId
6651
6740
  });
6652
6741
  if (verbose) {
6653
6742
  process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
@@ -6663,8 +6752,14 @@ ${PROJECT_RESET_LINE}
6663
6752
  `);
6664
6753
  }
6665
6754
  }
6666
- if (result.collidedPaths.length > 0) {
6667
- process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
6755
+ const edited = new Set(result.editedCollidedPaths);
6756
+ const trueCollisions = result.collidedPaths.filter((path) => !edited.has(path));
6757
+ if (result.editedCollidedPaths.length > 0) {
6758
+ process.stdout.write(`[halfcycle] Kept (your edited copy of an older Halfcycle file \u2014 NOT overwritten): ${result.editedCollidedPaths.join(", ")}
6759
+ `);
6760
+ }
6761
+ if (trueCollisions.length > 0) {
6762
+ process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${trueCollisions.join(", ")}
6668
6763
  `);
6669
6764
  }
6670
6765
  if (result.replacedPaths.length > 0) {
@@ -6719,8 +6814,10 @@ ${PROJECT_RESET_LINE}
6719
6814
  const identity = actingIdentity ?? heldAnswer?.identity ?? (credential && heldAnswer === void 0 ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
6720
6815
  const written = result.writtenPaths.length;
6721
6816
  const merged = result.mergedPaths.length;
6722
- process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose, controlOriginConfirmed)}
6817
+ if (identity !== void 0) {
6818
+ process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
6723
6819
  `);
6820
+ }
6724
6821
  process.stdout.write(`[halfcycle] Found: ${foundLine}
6725
6822
  `);
6726
6823
  process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, ${credential ? "guards armed on every edit" : "not checked \u2014 this copy is not this account\u2019s project"}
@@ -6732,7 +6829,8 @@ ${PROJECT_RESET_LINE}
6732
6829
  process.stdout.write(`[halfcycle] Commit the files Halfcycle set up here, including ${BUNDLE_PIN_REL} and ${PROJECT_IDENTITY_REL}, so every copy of this project joins it and runs the same checks.
6733
6830
  `);
6734
6831
  } else if (credential) {
6735
- process.stdout.write(`[halfcycle] This folder is not a git repository. Halfcycle's checks compare against your git history, so run "git init" here to get checks on every edit.
6832
+ process.stdout.write(process.platform !== "win32" && !gitIsInstalled() ? `[halfcycle] Halfcycle needs Git, and it is not installed on this machine \u2014 get it from https://git-scm.com/downloads to get checks on every edit.
6833
+ ` : `[halfcycle] This folder is not a git repository. Halfcycle's checks compare against your git history, so run "git init" here to get checks on every edit.
6736
6834
  `);
6737
6835
  }
6738
6836
  if (credential) {
@@ -6759,22 +6857,23 @@ ${PROJECT_RESET_LINE}
6759
6857
  `);
6760
6858
  process.exit(0);
6761
6859
  } catch (err) {
6762
- if (err instanceof SignInRefused || err instanceof PreCreateRefused || err instanceof SettingsParseRefused) {
6763
- process.stderr.write(`halfcycle: ${err.message}
6764
- `);
6860
+ const stop = (message) => {
6861
+ const kept = readFolderId(targetRepo) !== void 0 ? `${KEPT_FOLDER_ID_LINE}
6862
+ ` : "";
6863
+ process.stderr.write(`${message}
6864
+ ${kept}`);
6765
6865
  process.exit(1);
6866
+ };
6867
+ if (err instanceof SignInRefused || err instanceof PreCreateRefused || err instanceof SettingsParseRefused) {
6868
+ stop(`halfcycle: ${err.message}`);
6766
6869
  return;
6767
6870
  }
6768
6871
  const fsCode = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
6769
6872
  if (fsCode !== void 0) {
6770
- process.stderr.write(`halfcycle: could not write to ${targetRepo} \u2014 check that you can write to this folder and run "npx halfcycle" again.
6771
- `);
6772
- process.exit(1);
6873
+ stop(`halfcycle: could not write to ${targetRepo} \u2014 check that you can write to this folder and run "npx halfcycle" again.`);
6773
6874
  return;
6774
6875
  }
6775
- process.stderr.write(`halfcycle install failed: ${err instanceof Error ? err.message : String(err)}
6776
- `);
6777
- process.exit(1);
6876
+ stop(`halfcycle install failed: ${err instanceof Error ? err.message : String(err)}`);
6778
6877
  }
6779
6878
  return;
6780
6879
  }