sisyphi 1.1.24 → 1.1.25

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
@@ -111,6 +111,9 @@ function deployStatePath(provider) {
111
111
  function deployStateBackupPath(provider) {
112
112
  return join(deployProviderDir(provider), "terraform.tfstate.bak");
113
113
  }
114
+ function deployRuntimePath(provider) {
115
+ return join(deployProviderDir(provider), "runtime.json");
116
+ }
114
117
  function deployCredsPath(provider) {
115
118
  return join(deployDir(), `${provider}.env`);
116
119
  }
@@ -169,7 +172,7 @@ __export(creds_exports, {
169
172
  writeTailscaleEnv: () => writeTailscaleEnv
170
173
  });
171
174
  import { chmodSync as chmodSync3, existsSync as existsSync24, mkdirSync as mkdirSync12, readFileSync as readFileSync27 } from "fs";
172
- import { createInterface as createInterface3 } from "readline";
175
+ import { createInterface as createInterface4 } from "readline";
173
176
  function ensureDeployDir() {
174
177
  const dir = deployDir();
175
178
  if (!existsSync24(dir)) mkdirSync12(dir, { recursive: true, mode: 448 });
@@ -207,7 +210,7 @@ function writeEnvFile(path, values) {
207
210
  chmodSync3(path, 384);
208
211
  }
209
212
  async function promptLine(question, hidden) {
210
- const rl = createInterface3({ input: process.stdin, output: process.stdout, terminal: true });
213
+ const rl = createInterface4({ input: process.stdin, output: process.stdout, terminal: true });
211
214
  if (hidden) {
212
215
  const stdout = process.stdout;
213
216
  const originalWrite = stdout.write.bind(stdout);
@@ -293,7 +296,7 @@ var init_creds = __esm({
293
296
 
294
297
  // src/cli/index.ts
295
298
  import { Command } from "commander";
296
- import { existsSync as existsSync28, mkdirSync as mkdirSync14, readFileSync as readFileSync30 } from "fs";
299
+ import { existsSync as existsSync29, mkdirSync as mkdirSync14, readFileSync as readFileSync31 } from "fs";
297
300
  import { dirname as dirname12, join as join26 } from "path";
298
301
  import { fileURLToPath as fileURLToPath5 } from "url";
299
302
 
@@ -354,6 +357,7 @@ import { execSync } from "child_process";
354
357
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from "fs";
355
358
  import { homedir as homedir2 } from "os";
356
359
  import { join as join2 } from "path";
360
+ import { createInterface } from "readline";
357
361
 
358
362
  // src/shared/keymap.ts
359
363
  function formatHelpForKeymap(km) {
@@ -1717,7 +1721,22 @@ function getExistingBinding(key, table = "root") {
1717
1721
  function isSisyphusBinding(binding) {
1718
1722
  return binding.includes("sisyphus");
1719
1723
  }
1720
- function setupTmuxKeybind(cycleKey = DEFAULT_CYCLE_KEY, prefixKey = DEFAULT_PREFIX_KEY) {
1724
+ async function confirmConfAppend(userConf, line) {
1725
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
1726
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1727
+ return new Promise((resolve11) => {
1728
+ const question = `
1729
+ Sisyphus needs to append one line to ${userConf} so its tmux keybindings persist:
1730
+ ${line}
1731
+
1732
+ Append it now? (y/N) `;
1733
+ rl.question(question, (answer) => {
1734
+ rl.close();
1735
+ resolve11(answer.trim().toLowerCase() === "y");
1736
+ });
1737
+ });
1738
+ }
1739
+ async function setupTmuxKeybind(cycleKey = DEFAULT_CYCLE_KEY, prefixKey = DEFAULT_PREFIX_KEY, opts = {}) {
1721
1740
  installAllScripts();
1722
1741
  if (!tmuxVersionAtLeast(3, 2)) {
1723
1742
  let version = "unknown";
@@ -1757,14 +1776,22 @@ ${bindings.join("\n")}
1757
1776
  const userConf = userTmuxConfPath();
1758
1777
  const markedSourceLine = `source-file ${confPath} ${SISYPHUS_CONF_MARKER}`;
1759
1778
  let persistedToConf = false;
1779
+ let appendDeclined = false;
1760
1780
  if (userConf !== null) {
1761
1781
  const contents = readFileSync(userConf, "utf8");
1762
- if (!contents.includes(confPath)) {
1763
- const separator = contents.endsWith("\n") ? "" : "\n";
1764
- writeFileSync(userConf, `${contents}${separator}${markedSourceLine}
1782
+ if (contents.includes(confPath)) {
1783
+ persistedToConf = true;
1784
+ } else {
1785
+ const shouldAppend = opts.assumeYes ? true : await confirmConfAppend(userConf, markedSourceLine);
1786
+ if (shouldAppend) {
1787
+ const separator = contents.endsWith("\n") ? "" : "\n";
1788
+ writeFileSync(userConf, `${contents}${separator}${markedSourceLine}
1765
1789
  `, "utf8");
1790
+ persistedToConf = true;
1791
+ } else {
1792
+ appendDeclined = true;
1793
+ }
1766
1794
  }
1767
- persistedToConf = true;
1768
1795
  }
1769
1796
  try {
1770
1797
  for (const b of bindings) {
@@ -1772,6 +1799,16 @@ ${bindings.join("\n")}
1772
1799
  }
1773
1800
  } catch {
1774
1801
  }
1802
+ if (appendDeclined && userConf !== null) {
1803
+ return {
1804
+ status: "conf-modification-declined",
1805
+ message: `Tmux keybindings applied to the live session, but not persisted.
1806
+ To persist them, add this line to ${userConf}:
1807
+ ${markedSourceLine}`,
1808
+ manualLine: markedSourceLine,
1809
+ userConf
1810
+ };
1811
+ }
1775
1812
  if (getExistingBinding(cycleKey) !== null && isSisyphusBinding(getExistingBinding(cycleKey))) {
1776
1813
  return {
1777
1814
  status: "already-installed",
@@ -2061,7 +2098,7 @@ async function ensureDaemonInstalled() {
2061
2098
  const plist = generatePlist(nodePath, daemonPath, logPath);
2062
2099
  writeFileSync2(plistPath(), plist, "utf8");
2063
2100
  execSync3(`launchctl load -w ${plistPath()}`);
2064
- const keybindResult = setupTmuxKeybind();
2101
+ const keybindResult = await setupTmuxKeybind();
2065
2102
  await ensureRequiredPlugins(process.cwd());
2066
2103
  printGettingStarted(keybindResult, sisyphusPlugin);
2067
2104
  }
@@ -2102,6 +2139,8 @@ function printGettingStarted(keybindResult, sisyphusPlugin) {
2102
2139
  lines.push(`Tmux keybind: ${keybindResult.message}`, "");
2103
2140
  } else if (keybindResult.status === "conflict") {
2104
2141
  lines.push(`Keybind: ${keybindResult.message}`, "");
2142
+ } else if (keybindResult.status === "conf-modification-declined") {
2143
+ lines.push(keybindResult.message, "");
2105
2144
  }
2106
2145
  if (sisyphusPlugin.installed && sisyphusPlugin.autoInstalled) {
2107
2146
  lines.push(`Sisyphus plugin installed: sisyphus@sisyphus \u2192 ${sisyphusPlugin.installPath}`, "");
@@ -3073,17 +3112,21 @@ function inlineBodyPath(deckPath, bodyPath) {
3073
3112
  const deckDir = dirname2(deckPath);
3074
3113
  const joined = resolve2(deckDir, bodyPath);
3075
3114
  if (!existsSync5(joined)) {
3076
- throw new Error(`bodyPath does not exist: ${bodyPath}`);
3115
+ throw new Error(
3116
+ `bodyPath does not exist: '${bodyPath}' (resolved against deck dir '${deckDir}'). bodyPath is interpreted relative to the deck JSON's directory; place the body file there and use a relative path (e.g. "completion-summary.md").`
3117
+ );
3077
3118
  }
3078
3119
  const stat = lstatSync(joined);
3079
3120
  if (!stat.isFile()) {
3080
- throw new Error(`bodyPath must be a regular file: ${bodyPath}`);
3121
+ throw new Error(`bodyPath must be a regular file (not a symlink, directory, or special file): ${bodyPath}`);
3081
3122
  }
3082
3123
  const realResolved = realpathSync(joined);
3083
3124
  const realDeckDir = realpathSync(deckDir);
3084
3125
  const prefix = realDeckDir + sep;
3085
3126
  if (realResolved !== realDeckDir && !realResolved.startsWith(prefix)) {
3086
- throw new Error(`bodyPath escapes deck directory (outside): ${bodyPath}`);
3127
+ throw new Error(
3128
+ `bodyPath '${bodyPath}' escapes the deck's directory ('${realDeckDir}'). bodyPath is resolved relative to the deck JSON file and must stay inside its directory (no '..', absolute paths pointing elsewhere, or symlinks out). Fix: write the deck JSON next to the body file (e.g. both inside $SISYPHUS_SESSION_DIR/context/) and use a relative path like "completion-summary.md".`
3129
+ );
3087
3130
  }
3088
3131
  return readFileSync8(joined, "utf-8");
3089
3132
  }
@@ -3469,7 +3512,42 @@ The CLI always blocks until the user answers (which can take 10+ minutes).
3469
3512
 
3470
3513
  For guidance on when to use a deck, how to design options the user can actually choose between, and how to bundle related questions into one deck, read the \`humanloop\` skill before authoring.
3471
3514
 
3472
- Deck JSON: an object with \`interactions: [{ id, title, options, kind?, allowFreetext?, body? | bodyPath?, ... }]\`. Validation errors at submit are precise \u2014 trust them.
3515
+ DECK JSON SCHEMA
3516
+ { "title"?: string, "interactions": Interaction[] } // interactions[] non-empty
3517
+
3518
+ Interaction:
3519
+ id string, /^[A-Za-z0-9_-]+$/, max 64 chars, unique within deck
3520
+ title string (required, non-empty)
3521
+ subtitle? string
3522
+ body? string // markdown rendered in dashboard
3523
+ bodyPath? string // path RELATIVE to the deck JSON's directory
3524
+ // and must resolve INSIDE that directory
3525
+ // (no '..', no symlinks out, no absolute
3526
+ // paths pointing elsewhere). Mutually
3527
+ // exclusive with 'body'. To use bodyPath,
3528
+ // write the deck JSON next to the markdown
3529
+ // file (e.g. both in
3530
+ // $SISYPHUS_SESSION_DIR/context/) and pass
3531
+ // a basename like "summary.md".
3532
+ kind? "notify" | "validation" | "decision" | "context" | "error"
3533
+ // display hint for inbox icon/sort weight.
3534
+ // No other values accepted.
3535
+ options Option[] // 2\u20134 options recommended (see humanloop)
3536
+ allowFreetext? boolean
3537
+ freetextLabel? string
3538
+
3539
+ Option:
3540
+ id string (required)
3541
+ label string (required)
3542
+ description? string
3543
+ shortcut? string
3544
+
3545
+ OUTPUT
3546
+ On answer, stdout is one line of JSON:
3547
+ { "responses": [{ "id", "selectedOptionId"?, "freetext"? }, ...], "completedAt" }
3548
+ Branch on each response by its interaction \`id\`.
3549
+
3550
+ Validation errors at submit are precise \u2014 read them, don't guess.
3473
3551
  `).action(async (file, opts) => {
3474
3552
  if (!file) {
3475
3553
  ask.help();
@@ -4823,7 +4901,7 @@ function printResults(result, daemonOk, keybindMsg) {
4823
4901
  console.log("");
4824
4902
  }
4825
4903
  function registerSetup(program2) {
4826
- program2.command("setup").description("One-time setup: install dependencies, daemon, keybindings, and commands").action(async () => {
4904
+ program2.command("setup").description("One-time setup: install dependencies, daemon, keybindings, and commands").option("-y, --yes", "Skip confirmation prompts (e.g. before modifying ~/.tmux.conf)").action(async (opts) => {
4827
4905
  const result = runOnboarding();
4828
4906
  let daemonOk = false;
4829
4907
  try {
@@ -4832,7 +4910,11 @@ function registerSetup(program2) {
4832
4910
  } catch {
4833
4911
  daemonOk = isInstalled();
4834
4912
  }
4835
- const keybindResult = setupTmuxKeybind();
4913
+ const keybindResult = await setupTmuxKeybind(
4914
+ DEFAULT_CYCLE_KEY,
4915
+ DEFAULT_PREFIX_KEY,
4916
+ { assumeYes: opts.yes }
4917
+ );
4836
4918
  let keybindMsg;
4837
4919
  if (keybindResult.status === "installed" || keybindResult.status === "already-installed") {
4838
4920
  keybindMsg = `${DEFAULT_CYCLE_KEY} cycle, ${DEFAULT_PREFIX_KEY} prefix (h=dashboard, x=kill)`;
@@ -4845,9 +4927,9 @@ function registerSetup(program2) {
4845
4927
 
4846
4928
  // src/cli/commands/setup-keybind.ts
4847
4929
  function registerSetupKeybind(program2) {
4848
- program2.command("setup-keybind [cycle-key]").description("Install sisyphus tmux keybindings (default: M-s cycle, C-s prefix)").action(async (key) => {
4930
+ program2.command("setup-keybind [cycle-key]").description("Install sisyphus tmux keybindings (default: M-s cycle, C-s prefix)").option("-y, --yes", "Skip confirmation prompt before modifying ~/.tmux.conf").action(async (key, opts) => {
4849
4931
  const resolvedKey = key ?? DEFAULT_CYCLE_KEY;
4850
- const result = setupTmuxKeybind(resolvedKey);
4932
+ const result = await setupTmuxKeybind(resolvedKey, void 0, { assumeYes: opts.yes });
4851
4933
  switch (result.status) {
4852
4934
  case "installed":
4853
4935
  console.log(result.message);
@@ -4868,6 +4950,11 @@ function registerSetupKeybind(program2) {
4868
4950
  case "unsupported-tmux":
4869
4951
  console.log(result.message);
4870
4952
  break;
4953
+ case "conf-modification-declined":
4954
+ console.log(result.message);
4955
+ console.log("");
4956
+ console.log("Re-run with --yes to skip the prompt.");
4957
+ break;
4871
4958
  }
4872
4959
  });
4873
4960
  }
@@ -5208,9 +5295,9 @@ function registerInit(program2) {
5208
5295
  }
5209
5296
 
5210
5297
  // src/cli/commands/uninstall.ts
5211
- import { createInterface } from "readline";
5298
+ import { createInterface as createInterface2 } from "readline";
5212
5299
  async function confirm(question) {
5213
- const rl = createInterface({ input: process.stdin, output: process.stdout });
5300
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
5214
5301
  return new Promise((resolve11) => {
5215
5302
  rl.question(question, (answer) => {
5216
5303
  rl.close();
@@ -5235,12 +5322,12 @@ function registerUninstall(program2) {
5235
5322
  // src/cli/commands/configure-upload.ts
5236
5323
  init_paths();
5237
5324
  import { chmodSync as chmodSync2, existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync19, writeFileSync as writeFileSync10 } from "fs";
5238
- import { createInterface as createInterface2 } from "readline";
5325
+ import { createInterface as createInterface3 } from "readline";
5239
5326
  import { dirname as dirname6 } from "path";
5240
5327
  async function readUrlFromInput(interactive) {
5241
5328
  if (interactive) {
5242
5329
  return new Promise((resolve11) => {
5243
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
5330
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
5244
5331
  rl.question("Paste the upload URL (with embedded ?token=): ", (answer) => {
5245
5332
  rl.close();
5246
5333
  resolve11(answer.trim());
@@ -9366,7 +9453,7 @@ import { join as join25 } from "path";
9366
9453
  // src/cli/deploy/runner.ts
9367
9454
  init_paths();
9368
9455
  import { spawn as spawn2, spawnSync as spawnSync3 } from "child_process";
9369
- import { copyFileSync as copyFileSync2, existsSync as existsSync26, mkdirSync as mkdirSync13, readFileSync as readFileSync28 } from "fs";
9456
+ import { copyFileSync as copyFileSync2, existsSync as existsSync27, mkdirSync as mkdirSync13, readFileSync as readFileSync29 } from "fs";
9370
9457
  init_creds();
9371
9458
 
9372
9459
  // src/cli/deploy/pricing.ts
@@ -9399,16 +9486,82 @@ function formatCostLine(provider, instanceType) {
9399
9486
  return `Estimated cost: ~$${cost.toFixed(2)}/mo (pricing last verified ${LAST_VERIFIED}; verify against your bill for current rates).`;
9400
9487
  }
9401
9488
 
9489
+ // src/cli/deploy/runtime.ts
9490
+ init_atomic();
9491
+ init_paths();
9492
+ import { existsSync as existsSync25, readFileSync as readFileSync28, unlinkSync as unlinkSync4 } from "fs";
9493
+ function readRuntimeState(provider) {
9494
+ const path = deployRuntimePath(provider);
9495
+ if (!existsSync25(path)) return null;
9496
+ try {
9497
+ return JSON.parse(readFileSync28(path, "utf-8"));
9498
+ } catch {
9499
+ return null;
9500
+ }
9501
+ }
9502
+ function writeRuntimeState(provider, state) {
9503
+ atomicWrite(deployRuntimePath(provider), JSON.stringify(state, null, 2) + "\n");
9504
+ }
9505
+ function clearRuntimeState(provider) {
9506
+ const path = deployRuntimePath(provider);
9507
+ if (existsSync25(path)) unlinkSync4(path);
9508
+ }
9509
+
9510
+ // src/cli/deploy/tailnet.ts
9511
+ function discoverNode(requestedName) {
9512
+ const json = execSafe("tailscale status --json");
9513
+ if (!json) return null;
9514
+ let status;
9515
+ try {
9516
+ status = JSON.parse(json);
9517
+ } catch {
9518
+ return null;
9519
+ }
9520
+ const peers = status.Peer === void 0 ? [] : Object.values(status.Peer);
9521
+ const candidates = peers.filter(
9522
+ (p) => p.HostName === requestedName && p.Online === true
9523
+ );
9524
+ if (candidates.length === 0) return null;
9525
+ candidates.sort((a, b) => {
9526
+ const ac = a.Created === void 0 ? "" : a.Created;
9527
+ const bc = b.Created === void 0 ? "" : b.Created;
9528
+ return bc.localeCompare(ac);
9529
+ });
9530
+ const peer = candidates[0];
9531
+ const dnsRaw = peer.DNSName === void 0 ? "" : peer.DNSName;
9532
+ const dns = dnsRaw.replace(/\.$/, "");
9533
+ if (!dns) return null;
9534
+ const shortName = dns.split(".")[0];
9535
+ const ips = peer.TailscaleIPs === void 0 ? [] : peer.TailscaleIPs;
9536
+ const ipv4 = ips.find((ip) => /^\d+\.\d+\.\d+\.\d+$/.test(ip));
9537
+ if (!ipv4) return null;
9538
+ const ipv6 = ips.find((ip) => ip.includes(":")) ?? null;
9539
+ return { shortName, magicDnsName: dns, ipv4, ipv6 };
9540
+ }
9541
+ async function discoverNodeWithRetry(requestedName, maxRetries = 30, intervalMs = 2e3) {
9542
+ for (let i = 0; i < maxRetries; i++) {
9543
+ const node = discoverNode(requestedName);
9544
+ if (node) return node;
9545
+ if (i < maxRetries - 1) {
9546
+ await new Promise((resolve11) => setTimeout(resolve11, intervalMs));
9547
+ }
9548
+ }
9549
+ return null;
9550
+ }
9551
+ function isTailscaleAvailable() {
9552
+ return execSafe("tailscale version") !== null;
9553
+ }
9554
+
9402
9555
  // src/cli/deploy/templates.ts
9403
- import { existsSync as existsSync25 } from "fs";
9556
+ import { existsSync as existsSync26 } from "fs";
9404
9557
  import { dirname as dirname11, resolve as resolve10 } from "path";
9405
9558
  import { fileURLToPath as fileURLToPath4 } from "url";
9406
9559
  function deployRoot() {
9407
9560
  const here = dirname11(fileURLToPath4(import.meta.url));
9408
9561
  const bundled = resolve10(here, "..", "deploy");
9409
- if (existsSync25(bundled)) return bundled;
9562
+ if (existsSync26(bundled)) return bundled;
9410
9563
  const sourceRoot = resolve10(here, "..", "..", "..", "deploy");
9411
- if (existsSync25(sourceRoot)) return sourceRoot;
9564
+ if (existsSync26(sourceRoot)) return sourceRoot;
9412
9565
  throw new Error(
9413
9566
  `Could not locate deploy/ templates. Looked at:
9414
9567
  ${bundled}
@@ -9445,9 +9598,10 @@ async function firstRunPrompt() {
9445
9598
  console.log("");
9446
9599
  console.log("Tailscale credentials not configured. Pick one:");
9447
9600
  console.log("");
9448
- console.log(" 1) OAuth client (recommended) \u2014 mints fresh ephemeral keys per box.");
9601
+ console.log(" 1) OAuth client (recommended) \u2014 mints fresh ephemeral keys per box");
9602
+ console.log(" and auto-cleans stale offline nodes so hostnames don't get suffixed.");
9449
9603
  console.log(" Create at https://login.tailscale.com/admin/settings/oauth");
9450
- console.log(" with scope `auth_keys:write` and tag `tag:sisyphus`.");
9604
+ console.log(" with scopes `auth_keys:write` + `devices:write` and tag `tag:sisyphus`.");
9451
9605
  console.log(" 2) Reusable auth key (simpler) \u2014 paste from");
9452
9606
  console.log(" https://login.tailscale.com/admin/settings/keys");
9453
9607
  console.log("");
@@ -9469,6 +9623,14 @@ async function firstRunPrompt() {
9469
9623
  }
9470
9624
  async function mintViaOAuth(clientId, clientSecret, tag, hostname) {
9471
9625
  const token = await fetchAccessToken(clientId, clientSecret);
9626
+ try {
9627
+ const removed = await deleteStaleDevicesForHostname(token, hostname);
9628
+ if (removed > 0) {
9629
+ console.log(`Tailscale: removed ${removed} stale offline node(s) named "${hostname}".`);
9630
+ }
9631
+ } catch (err) {
9632
+ console.log(`Tailscale: skipped stale-node cleanup (${err.message}). Add 'devices:write' scope to clean up automatically.`);
9633
+ }
9472
9634
  const body = {
9473
9635
  capabilities: {
9474
9636
  devices: {
@@ -9499,6 +9661,32 @@ async function mintViaOAuth(clientId, clientSecret, tag, hostname) {
9499
9661
  if (!data.key) throw new Error("Tailscale API returned no key.");
9500
9662
  return data.key;
9501
9663
  }
9664
+ async function deleteStaleDevicesForHostname(token, hostname) {
9665
+ const res = await fetch(`${TS_API}/tailnet/-/devices`, {
9666
+ headers: { Authorization: `Bearer ${token}` }
9667
+ });
9668
+ if (!res.ok) {
9669
+ throw new Error(`HTTP ${res.status} listing devices`);
9670
+ }
9671
+ const data = await res.json();
9672
+ const STALE_AFTER_MS = 5 * 60 * 1e3;
9673
+ const now = Date.now();
9674
+ const stale = data.devices.filter((d) => {
9675
+ if (d.hostname !== hostname) return false;
9676
+ const seen = Date.parse(d.lastSeen);
9677
+ if (Number.isNaN(seen)) return false;
9678
+ return now - seen > STALE_AFTER_MS;
9679
+ });
9680
+ let removed = 0;
9681
+ for (const device of stale) {
9682
+ const r = await fetch(`${TS_API}/device/${device.id}`, {
9683
+ method: "DELETE",
9684
+ headers: { Authorization: `Bearer ${token}` }
9685
+ });
9686
+ if (r.ok) removed++;
9687
+ }
9688
+ return removed;
9689
+ }
9502
9690
  async function fetchAccessToken(clientId, clientSecret) {
9503
9691
  const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
9504
9692
  const res = await fetch(`${TS_API}/oauth/token`, {
@@ -9557,14 +9745,14 @@ function ensureTerraformInstalled() {
9557
9745
  function ensureProviderStateDir(provider) {
9558
9746
  ensureDeployDir();
9559
9747
  const dir = deployProviderDir(provider);
9560
- if (!existsSync26(dir)) mkdirSync13(dir, { recursive: true, mode: 448 });
9748
+ if (!existsSync27(dir)) mkdirSync13(dir, { recursive: true, mode: 448 });
9561
9749
  }
9562
9750
  function backupState(provider) {
9563
9751
  const src = deployStatePath(provider);
9564
- if (existsSync26(src)) copyFileSync2(src, deployStateBackupPath(provider));
9752
+ if (existsSync27(src)) copyFileSync2(src, deployStateBackupPath(provider));
9565
9753
  }
9566
9754
  function readSshPubkey(path) {
9567
- if (!existsSync26(path)) {
9755
+ if (!existsSync27(path)) {
9568
9756
  const privateKeyPath = path.replace(/\.pub$/, "");
9569
9757
  throw new Error(
9570
9758
  `SSH pubkey not found at ${path}. Generate one with:
@@ -9572,7 +9760,7 @@ function readSshPubkey(path) {
9572
9760
  or pass --ssh-key <path>.`
9573
9761
  );
9574
9762
  }
9575
- return readFileSync28(path, "utf-8").trim();
9763
+ return readFileSync29(path, "utf-8").trim();
9576
9764
  }
9577
9765
  function readOutputs(provider) {
9578
9766
  const result = spawnSync3("terraform", ["output", "-json", `-state=${deployStatePath(provider)}`], {
@@ -9599,10 +9787,13 @@ function readOutputs(provider) {
9599
9787
  }
9600
9788
  }
9601
9789
  function isProvisioned(provider) {
9602
- if (!existsSync26(deployStatePath(provider))) return false;
9790
+ if (!existsSync27(deployStatePath(provider))) return false;
9603
9791
  return readOutputs(provider) !== null;
9604
9792
  }
9605
9793
  async function deployUp(provider, opts) {
9794
+ if (isProvisioned(provider) && !await confirmReprovision(provider, opts.yes)) {
9795
+ return;
9796
+ }
9606
9797
  const sshPubkey = readSshPubkey(opts.sshKey);
9607
9798
  const creds = await loadProviderCreds(provider);
9608
9799
  const tsAuthKey = await mintTailscaleKey({ hostname: opts.name });
@@ -9650,16 +9841,39 @@ Applied \u2014 but could not parse outputs. Run \`sisyphus deploy ${provider} st
9650
9841
  console.log("");
9651
9842
  console.log("Box provisioned. Cloud-init will run for ~3\u20135 minutes before the daemon is reachable.");
9652
9843
  console.log("");
9653
- console.log(` IP: ${outputs.ipv4}`);
9654
- console.log(` Tailscale hostname: ${outputs.tailscale_hostname}`);
9655
- console.log(` SSH: ${outputs.ssh_command}`);
9844
+ console.log(` Public IP: ${outputs.ipv4}`);
9845
+ console.log(` Requested hostname: ${outputs.tailscale_hostname}`);
9846
+ if (isTailscaleAvailable()) {
9847
+ process.stdout.write(" Waiting for tailnet join...");
9848
+ const node = await discoverNodeWithRetry(opts.name);
9849
+ if (node) {
9850
+ writeRuntimeState(provider, {
9851
+ tailscaleHostname: node.shortName,
9852
+ tailscaleFqdn: node.magicDnsName,
9853
+ tailscaleIpv4: node.ipv4,
9854
+ discoveredAt: (/* @__PURE__ */ new Date()).toISOString()
9855
+ });
9856
+ process.stdout.write(` joined as ${node.shortName} (${node.ipv4})
9857
+ `);
9858
+ if (node.shortName !== opts.name) {
9859
+ console.log(` Note: Tailscale suffixed the hostname because "${opts.name}" was already`);
9860
+ console.log(" claimed by an offline node. Delete the stale node at");
9861
+ console.log(" https://login.tailscale.com/admin/machines to reuse the original name.");
9862
+ }
9863
+ } else {
9864
+ process.stdout.write(" no peer matched after 60s\n");
9865
+ console.log(" (Cloud-init may still be installing Tailscale. Re-run `status` later.)");
9866
+ }
9867
+ } else {
9868
+ console.log(" (Local `tailscale` CLI not on PATH \u2014 skipping tailnet discovery.)");
9869
+ }
9656
9870
  console.log("");
9657
9871
  console.log(` Tail provisioning: sisyphus deploy ${provider} logs`);
9658
9872
  console.log(` Verify daemon: sisyphus deploy ${provider} ssh -- sisyphus admin doctor`);
9659
9873
  console.log("");
9660
9874
  }
9661
9875
  async function deployDown(provider, opts) {
9662
- if (!existsSync26(deployStatePath(provider))) {
9876
+ if (!existsSync27(deployStatePath(provider))) {
9663
9877
  console.log(`No ${provider} state found at ${deployStatePath(provider)}. Nothing to destroy.`);
9664
9878
  return;
9665
9879
  }
@@ -9690,6 +9904,7 @@ async function deployDown(provider, opts) {
9690
9904
  creds
9691
9905
  );
9692
9906
  if (code !== 0) throw new Error(`terraform destroy failed (exit ${code})`);
9907
+ clearRuntimeState(provider);
9693
9908
  console.log(`
9694
9909
  ${provider} box destroyed.`);
9695
9910
  }
@@ -9703,10 +9918,21 @@ function deployStatus(provider) {
9703
9918
  console.log(`${provider}: state present but outputs unreadable. Try \`sisyphus deploy ${provider} up\` to reconcile.`);
9704
9919
  return;
9705
9920
  }
9921
+ const runtime = readRuntimeState(provider);
9922
+ const effectiveHost = runtime ? runtime.tailscaleHostname : outputs.tailscale_hostname;
9706
9923
  console.log(`${provider}: provisioned`);
9707
- console.log(` IP: ${outputs.ipv4}`);
9708
- console.log(` Tailscale hostname: ${outputs.tailscale_hostname}`);
9709
- console.log(` SSH: ${outputs.ssh_command}`);
9924
+ console.log(` Public IP: ${outputs.ipv4}`);
9925
+ console.log(` Tailscale hostname: ${effectiveHost}`);
9926
+ if (runtime) {
9927
+ console.log(` Tailscale IPv4: ${runtime.tailscaleIpv4}`);
9928
+ console.log(` MagicDNS FQDN: ${runtime.tailscaleFqdn}`);
9929
+ if (runtime.tailscaleHostname !== outputs.tailscale_hostname) {
9930
+ console.log(` (Requested "${outputs.tailscale_hostname}" but Tailscale assigned "${runtime.tailscaleHostname}".)`);
9931
+ }
9932
+ } else {
9933
+ console.log(" (No tailnet runtime state \u2014 re-run `up` or check that local tailscale is logged in.)");
9934
+ }
9935
+ console.log(` SSH: ssh sisyphus@${effectiveHost}`);
9710
9936
  console.log(` Instance type: ${outputs.instance_type}`);
9711
9937
  console.log(` ${formatCostLine(provider, outputs.instance_type)}`);
9712
9938
  }
@@ -9717,16 +9943,17 @@ function deployListProviders() {
9717
9943
  console.log(` ${p.padEnd(10)} ${status}`);
9718
9944
  }
9719
9945
  }
9720
- function requireOutputs(provider) {
9721
- const o = readOutputs(provider);
9722
- if (!o) {
9946
+ function effectiveSshTarget(provider) {
9947
+ const runtime = readRuntimeState(provider);
9948
+ if (runtime) return `sisyphus@${runtime.tailscaleHostname}`;
9949
+ const outputs = readOutputs(provider);
9950
+ if (!outputs) {
9723
9951
  throw new Error(`${provider} not provisioned. Run \`sisyphus deploy ${provider} up\`.`);
9724
9952
  }
9725
- return o;
9953
+ return `sisyphus@${outputs.tailscale_hostname}`;
9726
9954
  }
9727
9955
  function deploySsh(provider, remoteCmd) {
9728
- const outputs = requireOutputs(provider);
9729
- const target = `sisyphus@${outputs.tailscale_hostname}`;
9956
+ const target = effectiveSshTarget(provider);
9730
9957
  const moshAvailable = spawnSync3("mosh", ["--version"], { stdio: "pipe", env: EXEC_ENV }).status === 0;
9731
9958
  const bin = moshAvailable && remoteCmd.length === 0 ? "mosh" : "ssh";
9732
9959
  const args2 = remoteCmd.length > 0 ? [target, ...remoteCmd] : [target];
@@ -9734,15 +9961,13 @@ function deploySsh(provider, remoteCmd) {
9734
9961
  child.on("exit", (code) => process.exit(code === null ? 1 : code));
9735
9962
  }
9736
9963
  function deployLogs(provider) {
9737
- const outputs = requireOutputs(provider);
9738
- const target = `sisyphus@${outputs.tailscale_hostname}`;
9964
+ const target = effectiveSshTarget(provider);
9739
9965
  const remoteCmd = "tail -F -n 200 /var/log/cloud-init-output.log ~/.sisyphus/daemon.log 2>/dev/null";
9740
9966
  const child = spawn2("ssh", [target, remoteCmd], { stdio: "inherit", env: EXEC_ENV });
9741
9967
  child.on("exit", (code) => process.exit(code === null ? 1 : code));
9742
9968
  }
9743
9969
  function deployUpdate(provider) {
9744
- const outputs = requireOutputs(provider);
9745
- const target = `sisyphus@${outputs.tailscale_hostname}`;
9970
+ const target = effectiveSshTarget(provider);
9746
9971
  const remoteCmd = "sudo npm i -g sisyphi@latest && systemctl --user restart sisyphusd && sisyphusd --version || true";
9747
9972
  const child = spawn2("ssh", [target, remoteCmd], { stdio: "inherit", env: EXEC_ENV });
9748
9973
  child.on("exit", (code) => process.exit(code === null ? 1 : code));
@@ -9752,6 +9977,30 @@ async function confirm2(prompt) {
9752
9977
  const answer = await promptLine2(`${prompt} `, false);
9753
9978
  return answer.toLowerCase() === "yes";
9754
9979
  }
9980
+ async function confirmReprovision(provider, yes) {
9981
+ const outputs = readOutputs(provider);
9982
+ console.log("");
9983
+ if (outputs) {
9984
+ console.log(`${provider} is already provisioned: "${outputs.tailscale_hostname}" (${outputs.instance_type}, ${outputs.ipv4}).`);
9985
+ } else {
9986
+ console.log(`${provider} state already exists at ${deployStatePath(provider)}.`);
9987
+ }
9988
+ if (provider === "hetzner") {
9989
+ console.log("Re-running `up` on Hetzner will DESTROY and RECREATE the box (user_data is ForceNew).");
9990
+ console.log("All on-box state \u2014 daemon history, sessions, anything not in your repo \u2014 will be lost.");
9991
+ } else {
9992
+ console.log("Re-running `up` on AWS updates user_data in state but does NOT recreate the instance.");
9993
+ console.log("Cloud-init won't re-run on the live box, and the freshly-minted Tailscale key will be wasted.");
9994
+ }
9995
+ console.log("");
9996
+ console.log(`To push a new sisyphus version: sisyphus deploy ${provider} update`);
9997
+ console.log(`To rebuild from scratch: sisyphus deploy ${provider} down && sisyphus deploy ${provider} up`);
9998
+ console.log("");
9999
+ if (yes) return true;
10000
+ const confirmed = await confirm2('Type "yes" to proceed anyway:');
10001
+ if (!confirmed) console.log("Aborted.");
10002
+ return confirmed;
10003
+ }
9755
10004
 
9756
10005
  // src/cli/commands/deploy.ts
9757
10006
  var PROVIDERS = ["hetzner", "aws"];
@@ -9771,7 +10020,8 @@ function resolveUpOptions(provider, raw) {
9771
10020
  const size = raw.size === void 0 ? null : raw.size;
9772
10021
  const withChromium = raw.chromium !== false;
9773
10022
  const enableAutoUpdate = raw.autoUpdate !== false;
9774
- return { region, arch, size, sshKey: raw.sshKey, name: raw.name, withChromium, enableAutoUpdate };
10023
+ const yes = raw.yes === true;
10024
+ return { region, arch, size, sshKey: raw.sshKey, name: raw.name, withChromium, enableAutoUpdate, yes };
9775
10025
  }
9776
10026
  function registerDeploy(program2) {
9777
10027
  const deploy = program2.command("deploy").description("Provision a Tailscale-only sisyphus box on Hetzner or AWS via Terraform.");
@@ -9788,7 +10038,7 @@ function registerDeploy(program2) {
9788
10038
  });
9789
10039
  for (const provider of PROVIDERS) {
9790
10040
  const sub = deploy.command(provider).description(`${provider} commands.`);
9791
- sub.command("up").description(`Provision the ${provider} box (terraform init \u2192 plan \u2192 apply).`).option("--region <region>", `Provider region (defaults: hetzner=nbg1, aws=us-east-1).`).option("--arch <arch>", "'arm' (default) or 'x86'. Picks the default --size and image.", "arm").option("--size <size>", "Instance type override (defaults follow --arch).").option("--ssh-key <path>", "Path to SSH public key.", join25(homedir11(), ".ssh", "id_ed25519.pub")).option("--no-chromium", "Skip headless Chromium install.").option("--no-auto-update", "Skip the daily auto-update systemd timer.").option("--name <name>", "Box hostname / Tailscale node name.", "sisyphus").action(async (raw) => {
10041
+ sub.command("up").description(`Provision the ${provider} box (terraform init \u2192 plan \u2192 apply).`).option("--region <region>", `Provider region (defaults: hetzner=nbg1, aws=us-east-1).`).option("--arch <arch>", "'arm' (default) or 'x86'. Picks the default --size and image.", "arm").option("--size <size>", "Instance type override (defaults follow --arch).").option("--ssh-key <path>", "Path to SSH public key.", join25(homedir11(), ".ssh", "id_ed25519.pub")).option("--no-chromium", "Skip headless Chromium install.").option("--no-auto-update", "Skip the daily auto-update systemd timer.").option("--name <name>", "Box hostname / Tailscale node name.", "sisyphus").option("-y, --yes", "Skip the re-provision confirmation prompt when state already exists.").action(async (raw) => {
9792
10042
  const opts = resolveUpOptions(provider, raw);
9793
10043
  await deployUp(provider, opts);
9794
10044
  });
@@ -9840,7 +10090,7 @@ function attachTmuxStatus(diagnostic2) {
9840
10090
  // src/cli/commands/tmux-sessions.ts
9841
10091
  init_paths();
9842
10092
  import { execSync as execSync15 } from "child_process";
9843
- import { readFileSync as readFileSync29, existsSync as existsSync27 } from "fs";
10093
+ import { readFileSync as readFileSync30, existsSync as existsSync28 } from "fs";
9844
10094
  var DOT_MAP = {
9845
10095
  "orchestrator:processing": { icon: "\u25CF", color: "#d4ad6a" },
9846
10096
  "orchestrator:idle": { icon: "\u25CF", color: "#d47766" },
@@ -9851,9 +10101,9 @@ var DOT_MAP = {
9851
10101
  };
9852
10102
  function readManifest() {
9853
10103
  const p = sessionsManifestPath();
9854
- if (!existsSync27(p)) return null;
10104
+ if (!existsSync28(p)) return null;
9855
10105
  try {
9856
- return JSON.parse(readFileSync29(p, "utf-8"));
10106
+ return JSON.parse(readFileSync30(p, "utf-8"));
9857
10107
  } catch {
9858
10108
  return null;
9859
10109
  }
@@ -9899,7 +10149,7 @@ if (nodeVersion < 22) {
9899
10149
  var program = new Command();
9900
10150
  program.name("sisyphus").description("tmux-integrated orchestration daemon for Claude Code").version(
9901
10151
  JSON.parse(
9902
- readFileSync30(join26(dirname12(fileURLToPath5(import.meta.url)), "..", "package.json"), "utf-8")
10152
+ readFileSync31(join26(dirname12(fileURLToPath5(import.meta.url)), "..", "package.json"), "utf-8")
9903
10153
  ).version
9904
10154
  );
9905
10155
  program.configureHelp({
@@ -9969,7 +10219,7 @@ Run 'sisyphus admin getting-started' for a complete usage guide.
9969
10219
  var args = process.argv.slice(2);
9970
10220
  var firstArg = args[0];
9971
10221
  var skipWelcome = ["admin", "help", "--help", "-h", "--version", "-V"];
9972
- if (!existsSync28(globalDir()) && firstArg && !skipWelcome.includes(firstArg)) {
10222
+ if (!existsSync29(globalDir()) && firstArg && !skipWelcome.includes(firstArg)) {
9973
10223
  mkdirSync14(globalDir(), { recursive: true });
9974
10224
  console.log("");
9975
10225
  console.log(" Welcome to Sisyphus. Run 'sisyphus admin setup' to get started.");