skydive-cli 0.2.0 → 0.3.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/dist/js/bin.mjs CHANGED
@@ -1,28 +1,26 @@
1
1
  #!/usr/bin/env node
2
- import { A as getShareMachineDefault, B as saveSession, C as DEFAULT_WEB_URL, D as getPromptHistoryPath, E as getLastSeenVersion, F as resolveConfig, H as setLastSeenVersion, I as resolveManagementAuth, L as resolveSession, M as getStoredApiKeyWorkspaceName, N as getUpdateCheckDisabled, P as resolveAppUrl, T as getConfigPath, _ as setActiveWorkspace, b as API_KEY_PREFIX, d as themes, g as listWorkspaces, h as getSessionIdentity, j as getStoredApiKeyId, m as getActiveWorkspaceId, p as ensureActiveOrganization, v as API_KEYS_URL, w as deleteConfig, x as DEFAULT_API_URL, y as API_KEY_FAMILY_PREFIX, z as saveConfig } from "./theme-CuQhvqzN.mjs";
3
- import { n as printError, r as printTable, t as output } from "./output-B4cW10Ph.mjs";
4
- import { n as createRestClient } from "./rest-CamHVOce.mjs";
5
- import { i as resolveAgent } from "./print-Bin4u16d.mjs";
6
- import { a as registerPortalDevice, c as machineIdentity, n as findThisDevice, o as revokePortalAccess, r as grantPortalAccess, t as fetchPortalDevices } from "./api-CDTKq_5Q.mjs";
7
- import { t as SandboxStream } from "./client-CpEvH2Pq.mjs";
2
+ import { C as setActiveWorkspace, S as listWorkspaces, T as version, _ as themes, b as getActiveWorkspaceId, o as brandHelpArt, t as installCrashHandler, w as name, x as getSessionIdentity, y as ensureActiveOrganization } from "./install-CMYBFvR2.mjs";
3
+ import { A as resolveAppUrl, C as getPromptHistoryPath, D as getStoredApiKeyId, E as getShareMachineDefault, F as saveConfig, I as saveSession, M as resolveManagementAuth, N as resolveSession, O as getStoredApiKeyWorkspaceName, R as setLastSeenVersion, S as getLastSeenVersion, _ as API_KEY_PREFIX, b as deleteConfig, g as API_KEY_FAMILY_PREFIX, h as API_KEYS_URL, i as resolveAgent, j as resolveConfig, k as getUpdateCheckDisabled, v as DEFAULT_API_URL, x as getConfigPath } from "./print-Cd-bVSEJ.mjs";
4
+ import { n as printError, r as printTable, t as output } from "./output-DYzzdXYV.mjs";
5
+ import { n as createRestClient, t as HttpError } from "./rest-BlN_uWmL.mjs";
6
+ import { a as registerPortalDevice, i as grantPortalAccess, n as fetchPortalDevices, o as revokePortalAccess, r as findThisDevice, s as machineIdentity } from "./client-Dc7GZ3PG.mjs";
7
+ import { c as PORTAL_DAEMON_FLAG, i as queryDaemonStatus, l as daemonPaths, n as ensureDaemonRunning, o as stopDaemon } from "./daemon-Co4CtpXZ.mjs";
8
+ import { t as SandboxStream } from "./client-DfcJFEbh.mjs";
8
9
  import { hideBin } from "yargs/helpers";
9
10
  import yargs from "yargs";
10
- import { hostname } from "node:os";
11
+ import os, { hostname } from "node:os";
11
12
  import path from "node:path";
12
13
  import { err, ok } from "neverthrow";
13
14
  import { z } from "zod";
14
15
  import open from "open";
16
+ import fs from "node:fs";
15
17
  import { spawn, spawnSync } from "node:child_process";
16
18
  import { createHash } from "node:crypto";
17
- import fs from "node:fs";
18
19
  import zlib from "node:zlib";
20
+ import fsp from "node:fs/promises";
19
21
  import semver from "semver";
22
+ import { createInterface } from "node:readline";
20
23
 
21
- //#region package.json
22
- var name = "skydive-cli";
23
- var version = "0.2.0";
24
-
25
- //#endregion
26
24
  //#region src/types.ts
27
25
  const NON_INTERACTIVE_ENV_VARS = [
28
26
  "CI",
@@ -715,6 +713,308 @@ const authCommand = {
715
713
  handler: () => {}
716
714
  };
717
715
 
716
+ //#endregion
717
+ //#region src/commands/completion.ts
718
+ /**
719
+ * The argv sentinel yargs answers with completion candidates for a partially
720
+ * typed command line. The scripts below are the only callers; `bin.ts` also
721
+ * matches on it to keep notices off the user's prompt (see isCompletionProbe).
722
+ */
723
+ const COMPLETION_PROBE_FLAG = "--get-yargs-completions";
724
+ const SUPPORTED_SHELLS = [
725
+ "bash",
726
+ "zsh",
727
+ "fish"
728
+ ];
729
+ /**
730
+ * True when this invocation is a shell asking for completion candidates
731
+ * rather than a user running a command. Such a run must print candidates and
732
+ * nothing else: stderr is still a TTY inside a completion function, so an
733
+ * update notice would be drawn onto the prompt line, and the what's-new
734
+ * notice is one-shot — a TAB press would consume it unread.
735
+ */
736
+ function isCompletionProbe(argv) {
737
+ return argv.includes(COMPLETION_PROBE_FLAG);
738
+ }
739
+ /**
740
+ * Blank the word the cursor is still on before yargs sees a probe.
741
+ *
742
+ * yargs' completion walk treats *every* token in the probe as a finished
743
+ * command, the word under the cursor included: it scans the args for one that
744
+ * names a command, descends into that command's builder, and answers from
745
+ * there. So a fully typed command name answers with nothing —
746
+ * `skydive conv<TAB>` descends into the `conv` alias of `conversations` and
747
+ * emits an empty list, which leaves an alias that can never be expanded and a
748
+ * `skydive agents<TAB>` that won't even add its trailing space. In bash it is
749
+ * worse than a no-op: `complete -o default` reads the empty list as "no
750
+ * candidates" and offers local *filenames* instead.
751
+ *
752
+ * The three scripts all send the partial word as the last token — that is
753
+ * yargs' wire format, not something we chose — so drop it here and let the
754
+ * shell filter the full candidate list by prefix. That is already what happens
755
+ * for any partial that doesn't happen to name a command (`skydive conve<TAB>`
756
+ * gets all 13 commands back today and bash/zsh/fish narrow them), so this only
757
+ * removes the special case, it doesn't change the contract.
758
+ *
759
+ * Two tokens are left alone:
760
+ * - one starting with `-`, because yargs uses exactly that to decide between
761
+ * offering options and offering subcommands, and
762
+ * - the command name itself, so a probe never degrades to an empty script name.
763
+ */
764
+ function normalizeProbeArgv(argv) {
765
+ const flag = argv.indexOf(COMPLETION_PROBE_FLAG);
766
+ if (flag === -1 || argv.length - flag < 3) return argv;
767
+ const last = argv[argv.length - 1] ?? "";
768
+ if (last === "" || last.startsWith("-")) return argv;
769
+ return [...argv.slice(0, -1), ""];
770
+ }
771
+ const blockStart = (name) => `###-begin-${name}-completions-###`;
772
+ const blockEnd = (name) => `###-end-${name}-completions-###`;
773
+ /**
774
+ * Emit a completion script that asks the CLI itself for candidates on every
775
+ * TAB, rather than baking today's command tree into the script. bash and zsh
776
+ * are adapted from yargs' own templates
777
+ * (node_modules/yargs/build/lib/completion-templates.js); fish is
778
+ * hand-written, as yargs ships no fish template. Differences from yargs:
779
+ *
780
+ * - The shell is an explicit argument, not inferred from `$SHELL` at generate
781
+ * time, so `skydive completion zsh` is meaningful from any shell.
782
+ * - The script calls `skydive` off PATH instead of the absolute path of the
783
+ * generating process, which would go stale on every version bump under a
784
+ * version manager.
785
+ * - The probe's wire format is pinned. yargs picks it from `SHELL`/`ZSH_NAME`
786
+ * — plain lines normally, `value:description` when either says "zsh" — so
787
+ * a bash user whose login shell is zsh would otherwise get descriptions
788
+ * stuffed into COMPREPLY. bash asks for plain lines; zsh and fish ask for
789
+ * the descriptive format and render the descriptions.
790
+ */
791
+ function renderCompletionScript(shell, commandName = "skydive") {
792
+ if (shell === "zsh") return zshScript(commandName);
793
+ if (shell === "fish") return fishScript(commandName);
794
+ return bashScript(commandName);
795
+ }
796
+ function bashScript(name) {
797
+ const fn = `_${name}_completions`;
798
+ return `${blockStart(name)}
799
+ #
800
+ # ${name} command completion script, generated by \`${name} completion bash\`.
801
+ # Installed by \`${name} completion install bash\`; edits inside this block
802
+ # are overwritten on the next install.
803
+ #
804
+ ${fn}()
805
+ {
806
+ local cur_word args candidates
807
+
808
+ cur_word="\${COMP_WORDS[COMP_CWORD]}"
809
+ args=("\${COMP_WORDS[@]}")
810
+
811
+ candidates=$(SHELL=bash ZSH_NAME= ${name} ${COMPLETION_PROBE_FLAG} "\${args[@]}" 2>/dev/null)
812
+
813
+ COMPREPLY=( $(compgen -W "\${candidates}" -- "\${cur_word}") )
814
+
815
+ return 0
816
+ }
817
+ complete -o bashdefault -o default -F ${fn} ${name}
818
+ ${blockEnd(name)}
819
+ `;
820
+ }
821
+ function zshScript(name) {
822
+ const fn = `_${name}_completions`;
823
+ return `${blockStart(name)}
824
+ #
825
+ # ${name} command completion script, generated by \`${name} completion zsh\`.
826
+ # Installed by \`${name} completion install zsh\`; edits inside this block
827
+ # are overwritten on the next install.
828
+ #
829
+ ${fn}()
830
+ {
831
+ local reply
832
+ local si=$IFS
833
+ IFS=$'\\n' reply=($(COMP_CWORD="$((CURRENT-1))" COMP_LINE="$BUFFER" COMP_POINT="$CURSOR" SHELL=zsh ${name} ${COMPLETION_PROBE_FLAG} "\${words[@]}" 2>/dev/null))
834
+ IFS=$si
835
+ _describe 'values' reply
836
+ }
837
+ # compdef only exists once compinit has run. Sourcing this block from a .zshrc
838
+ # that never calls compinit (or calls it later) would otherwise fail with
839
+ # "command not found: compdef".
840
+ #
841
+ # -i is load-bearing: a bare compinit that finds an insecure directory on fpath
842
+ # stops to ask "Ignore insecure directories and continue?", and where it cannot
843
+ # open the terminal it prints "compinit: initialization aborted" and defines no
844
+ # compdef at all -- so the line below fails and the user gets no completions.
845
+ # An insecure fpath entry is ordinary, not exotic: Homebrew's
846
+ # share/zsh/site-functions is writable by the installing user. -i skips those
847
+ # directories and carries on, which beats prompting from someone's shell
848
+ # startup.
849
+ if ! type compdef >/dev/null 2>&1; then
850
+ autoload -Uz compinit && compinit -i
851
+ fi
852
+ compdef ${fn} ${name}
853
+ ${blockEnd(name)}
854
+ `;
855
+ }
856
+ function fishScript(name) {
857
+ const fn = `__${name}_completions`;
858
+ return `${blockStart(name)}
859
+ #
860
+ # ${name} command completion script, generated by \`${name} completion fish\`.
861
+ # Installed by \`${name} completion install fish\`; edits are overwritten on
862
+ # the next install.
863
+ #
864
+ function ${fn}
865
+ set -l tokens (commandline --current-process --tokenize --cut-at-cursor)
866
+ set -l current (commandline --current-token --cut-at-cursor)
867
+
868
+ for line in (ZSH_NAME=zsh ${name} ${COMPLETION_PROBE_FLAG} $tokens "$current" 2>/dev/null)
869
+ set -l masked (string replace --all -- '\\:' \\x01 $line)
870
+ set -l parts (string split --max 1 -- ':' $masked)
871
+ set -l value (string replace --all -- \\x01 ':' $parts[1])
872
+
873
+ # Filter by the partial token ourselves. fish 3.3 and older (Ubuntu
874
+ # 22.04 LTS ships 3.3.1) skip their own matching for any candidate that
875
+ # carries a description, so \`${name} ag<TAB>\` there offers every
876
+ # command instead of the one that matches -- and a description is the
877
+ # whole reason this script asks for the descriptive wire format. Newer
878
+ # fish filters, so this is a no-op on it. Compared by length rather
879
+ # than \`string match\` so a token holding a glob character stays
880
+ # literal. Prefix matching is also what bash's \`compgen -W\` and zsh's
881
+ # \`_describe\` do, so the three shells now agree.
882
+ if test (string sub --length (string length -- "$current") -- "$value") != "$current"
883
+ continue
884
+ end
885
+
886
+ if set -q parts[2]
887
+ printf '%s\\t%s\\n' $value (string replace --all -- \\x01 ':' $parts[2])
888
+ else
889
+ printf '%s\\n' $value
890
+ end
891
+ end
892
+ end
893
+
894
+ # -f: only our candidates, no filename fallback. -k: keep the CLI's own order
895
+ # instead of fish's alphabetical sort.
896
+ complete -c ${name} -f -k -a '(${fn})'
897
+ ${blockEnd(name)}
898
+ `;
899
+ }
900
+ const defaultInstallEnv = () => ({
901
+ home: os.homedir(),
902
+ platform: process.platform,
903
+ env: process.env,
904
+ exists: fs.existsSync
905
+ });
906
+ /**
907
+ * The shell to install for when the user didn't name one. `$SHELL` is the
908
+ * only signal available: a completion install runs as a child process, so the
909
+ * invoking shell's own variables (`$ZSH_VERSION`, `$FISH_VERSION`) aren't
910
+ * visible here.
911
+ */
912
+ function detectShell(env = process.env) {
913
+ const shell = env["SHELL"];
914
+ if (!shell) return null;
915
+ const name = path.basename(shell);
916
+ return SUPPORTED_SHELLS.find((candidate) => name === candidate) ?? null;
917
+ }
918
+ function completionTarget(shell, deps = defaultInstallEnv()) {
919
+ const { home, platform, env, exists } = deps;
920
+ if (shell === "fish") {
921
+ const configHome = env["XDG_CONFIG_HOME"] || path.join(home, ".config");
922
+ return {
923
+ path: path.join(configHome, "fish", "completions", "skydive.fish"),
924
+ mode: "file"
925
+ };
926
+ }
927
+ if (shell === "zsh") {
928
+ const zdotdir = env["ZDOTDIR"] || home;
929
+ return {
930
+ path: path.join(zdotdir, ".zshrc"),
931
+ mode: "block"
932
+ };
933
+ }
934
+ const rc = path.join(home, ".bashrc");
935
+ const profile = path.join(home, ".bash_profile");
936
+ const preferred = platform === "darwin" ? profile : rc;
937
+ const alternate = platform === "darwin" ? rc : profile;
938
+ if (!exists(preferred) && exists(alternate)) return {
939
+ path: alternate,
940
+ mode: "block"
941
+ };
942
+ return {
943
+ path: preferred,
944
+ mode: "block"
945
+ };
946
+ }
947
+ /**
948
+ * The startup file's contents with our block added or refreshed. Idempotent:
949
+ * a second install rewrites the block in place rather than appending a
950
+ * duplicate, which is also how an upgraded CLI ships a newer script.
951
+ */
952
+ function spliceCompletionBlock(existing, script, commandName = "skydive") {
953
+ const start = existing.indexOf(blockStart(commandName));
954
+ const endMarker = blockEnd(commandName);
955
+ const end = existing.indexOf(endMarker);
956
+ if (start !== -1 && end > start) {
957
+ const before = existing.slice(0, start);
958
+ const after = existing.slice(end + endMarker.length).replace(/^\n/, "");
959
+ return `${before}${script.trimEnd()}\n${after}`;
960
+ }
961
+ return `${existing}${existing === "" || existing.endsWith("\n\n") ? "" : "\n"}\n${script}`;
962
+ }
963
+ function installCompletionScript(shell, deps = defaultInstallEnv()) {
964
+ const target = completionTarget(shell, deps);
965
+ const script = renderCompletionScript(shell);
966
+ const existing = deps.exists(target.path) ? fs.readFileSync(target.path, "utf8") : "";
967
+ const contents = target.mode === "file" ? script : spliceCompletionBlock(existing, script);
968
+ fs.mkdirSync(path.dirname(target.path), { recursive: true });
969
+ fs.writeFileSync(target.path, contents, "utf8");
970
+ return {
971
+ shell,
972
+ path: target.path,
973
+ updated: existing.includes(blockStart("skydive"))
974
+ };
975
+ }
976
+ /** What the user has to do before completions work in the shell they're in. */
977
+ function activationHint(result) {
978
+ return result.shell === "fish" ? "Open a new fish shell to pick them up (fish autoloads completions)." : `Open a new shell, or run: source ${result.path}`;
979
+ }
980
+ const installCommand$1 = {
981
+ command: "install [shell]",
982
+ describe: "Install completions into your shell's startup file",
983
+ builder: (y) => y.positional("shell", {
984
+ type: "string",
985
+ describe: "Shell to install for (default: $SHELL)"
986
+ }).example("skydive completion install", "Install for the current shell").example("skydive completion install fish", "Install for a named shell"),
987
+ handler: async (argv) => {
988
+ const shell = argv.shell ? SUPPORTED_SHELLS.find((candidate) => candidate === argv.shell) : detectShell();
989
+ if (!shell) throw new Error(argv.shell ? `No completions for ${argv.shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}.` : `Could not tell which shell to install for from $SHELL. Name one: ${SUPPORTED_SHELLS.join(", ")}.`);
990
+ let result;
991
+ try {
992
+ result = installCompletionScript(shell);
993
+ } catch (error) {
994
+ throw new Error(`Could not write ${shell} completions: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
995
+ }
996
+ if (argv.json) {
997
+ output(argv, result);
998
+ return;
999
+ }
1000
+ console.log(`${result.updated ? "Updated" : "Installed"} ${result.shell} completions in ${result.path}`);
1001
+ if (!argv.quiet) console.log(activationHint(result));
1002
+ }
1003
+ };
1004
+ const completionCommand = {
1005
+ command: "completion [shell]",
1006
+ describe: "Print the shell completion script for bash, zsh, or fish",
1007
+ builder: (y) => y.positional("shell", {
1008
+ type: "string",
1009
+ choices: SUPPORTED_SHELLS,
1010
+ describe: "Shell to generate completions for"
1011
+ }).command(installCommand$1).example("skydive completion install", "Set up completions for your shell").example("eval \"$(skydive completion zsh)\"", "Enable completions for this shell only"),
1012
+ handler: async (argv) => {
1013
+ if (!argv.shell) throw new Error(`Specify a shell (${SUPPORTED_SHELLS.join(", ")}), or run \`skydive completion install\` to set up $SHELL.`);
1014
+ process.stdout.write(renderCompletionScript(argv.shell));
1015
+ }
1016
+ };
1017
+
718
1018
  //#endregion
719
1019
  //#region src/commands/session.ts
720
1020
  /**
@@ -1200,7 +1500,7 @@ const importCommand = {
1200
1500
  process.exit(1);
1201
1501
  }
1202
1502
  }
1203
- const { runChat } = await import("./boot-CGaXUEer.mjs");
1503
+ const { runChat } = await import("./boot-HAgDg_b4.mjs");
1204
1504
  await runChat({
1205
1505
  appUrl,
1206
1506
  sessionToken: session.value.sessionToken,
@@ -1209,6 +1509,7 @@ const importCommand = {
1209
1509
  notifications: true,
1210
1510
  agentSelector: argv.agent ?? null,
1211
1511
  conversationId: null,
1512
+ newConversation: false,
1212
1513
  seedPrompt: buildImportSeedPrompt(process.cwd())
1213
1514
  });
1214
1515
  }
@@ -1226,7 +1527,7 @@ async function runImportPrintMode({ argv, appUrl }) {
1226
1527
  printError(`${session.error.message} For non-interactive use, run \`skydive auth login --web\` first, or set SKYDIVE_SESSION_TOKEN.`);
1227
1528
  process.exit(1);
1228
1529
  }
1229
- const { connectMachineShare } = await import("./print-share-D7OSxvE2.mjs");
1530
+ const { connectMachineShare } = await import("./print-share-7qfpQtWK.mjs");
1230
1531
  const machineShare = await connectMachineShare({
1231
1532
  appUrl,
1232
1533
  sessionToken: session.value.sessionToken,
@@ -1234,7 +1535,7 @@ async function runImportPrintMode({ argv, appUrl }) {
1234
1535
  });
1235
1536
  const extra = (argv.print ?? "").trim();
1236
1537
  const prompt = buildImportSeedPrompt(process.cwd()) + (extra ? `\n\nAdditional instructions: ${extra}` : "");
1237
- const { runPrint } = await import("./print-BZH0b3KB.mjs");
1538
+ const { runPrint } = await import("./print-CPCFci60.mjs");
1238
1539
  try {
1239
1540
  const result = await runPrint({
1240
1541
  appUrl,
@@ -1449,6 +1750,97 @@ const secretsCommand = {
1449
1750
  handler: () => {}
1450
1751
  };
1451
1752
 
1753
+ //#endregion
1754
+ //#region src/auth/agent-workspace.ts
1755
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1756
+ /**
1757
+ * Cap on concurrent per-workspace probes. An account can be in a lot of
1758
+ * workspaces; firing one request per workspace all at once is a burst the
1759
+ * API (and the user's uplink) doesn't need. Four keeps the common case
1760
+ * (a handful of workspaces) effectively parallel while bounding the worst.
1761
+ */
1762
+ const PROBE_CONCURRENCY = 4;
1763
+ /**
1764
+ * `Promise.all` with at most `limit` tasks in flight — a dependency-free
1765
+ * stand-in for p-queue sized to this one call site. Results keep input
1766
+ * order.
1767
+ */
1768
+ async function mapWithConcurrency(items, limit, fn) {
1769
+ const results = [];
1770
+ const queue = items.entries();
1771
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1772
+ for (const [index, item] of queue) results[index] = await fn(item);
1773
+ });
1774
+ await Promise.all(workers);
1775
+ return results;
1776
+ }
1777
+ /**
1778
+ * Whether the agent is visible in one workspace, via the typed rest client
1779
+ * scoped with `x-workspace-id` (a per-request override — the session's
1780
+ * persisted active workspace is untouched, so probing is side-effect free).
1781
+ * A miss (404 unknown/hidden, 403 other org) or a network failure is simply
1782
+ * "not here" — if the API is truly unreachable the main request path fails
1783
+ * right after with its own (better) error.
1784
+ */
1785
+ async function probeAgent({ appUrl, sessionToken, agentId, workspaceId }) {
1786
+ const client = createRestClient({
1787
+ appUrl,
1788
+ sessionToken,
1789
+ ...workspaceId ? { workspaceId } : {}
1790
+ });
1791
+ try {
1792
+ return { name: (await client.getAgent({ agentId })).name };
1793
+ } catch (error) {
1794
+ if (error instanceof HttpError) return null;
1795
+ return null;
1796
+ }
1797
+ }
1798
+ async function resolveAgentWorkspace({ appUrl, sessionToken, agentSelector }) {
1799
+ if (!agentSelector || !uuidPattern.test(agentSelector)) return { kind: "active" };
1800
+ if (await probeAgent({
1801
+ appUrl,
1802
+ sessionToken,
1803
+ agentId: agentSelector,
1804
+ workspaceId: null
1805
+ })) return { kind: "active" };
1806
+ const [workspaces, activeId] = await Promise.all([listWorkspaces({
1807
+ appUrl,
1808
+ sessionToken
1809
+ }), getActiveWorkspaceId({
1810
+ appUrl,
1811
+ sessionToken
1812
+ })]);
1813
+ if (workspaces.isErr()) return {
1814
+ kind: "list-failed",
1815
+ message: workspaces.error.message
1816
+ };
1817
+ const found = (await mapWithConcurrency(workspaces.value.filter((w) => activeId.isErr() || w.id !== activeId.value), PROBE_CONCURRENCY, async (workspace) => ({
1818
+ workspace,
1819
+ hit: await probeAgent({
1820
+ appUrl,
1821
+ sessionToken,
1822
+ agentId: agentSelector,
1823
+ workspaceId: workspace.id
1824
+ })
1825
+ }))).find((p) => p.hit !== null);
1826
+ if (!found?.hit) return { kind: "not-found" };
1827
+ const switched = await setActiveWorkspace({
1828
+ appUrl,
1829
+ sessionToken,
1830
+ organizationId: found.workspace.id
1831
+ });
1832
+ if (switched.isErr()) return {
1833
+ kind: "switch-failed",
1834
+ workspace: found.workspace,
1835
+ message: switched.error.message
1836
+ };
1837
+ return {
1838
+ kind: "switched",
1839
+ workspace: found.workspace,
1840
+ agentName: found.hit.name
1841
+ };
1842
+ }
1843
+
1452
1844
  //#endregion
1453
1845
  //#region src/commands/chat.ts
1454
1846
  const chatCommand = {
@@ -1465,6 +1857,10 @@ const chatCommand = {
1465
1857
  alias: "resume",
1466
1858
  type: "string",
1467
1859
  describe: "Continue an existing conversation by id. With -p, the one-shot lands in that conversation; without -p, the TUI opens directly into it (the agent is resolved from the conversation, no --agent needed)."
1860
+ }).option("new", {
1861
+ type: "boolean",
1862
+ default: false,
1863
+ describe: "Start a fresh conversation instead of opening the conversation list. With --agent, opens a new conversation with that agent directly. Without --agent, opens a new conversation once you pick an agent (auto-picked if the account has only one). Ignored with --resume, which always continues the named conversation."
1468
1864
  }).option("share-machine", {
1469
1865
  type: "boolean",
1470
1866
  describe: "Share this machine with the agent over the portal so it can run commands here (default-deny; in the TUI you approve per agent, with -p the flag grants the target agent for the run). Defaults to `shareMachineDefault` in the CLI config file; --no-share-machine disables for this invocation."
@@ -1475,7 +1871,7 @@ const chatCommand = {
1475
1871
  type: "boolean",
1476
1872
  default: true,
1477
1873
  describe: "Show a desktop notification when a run finishes or needs your input while this terminal is unfocused (use --no-notify to disable)"
1478
- }).example("skydive chat -p \"summarize my open PRs\" --agent grace", "One-shot, non-interactive").example("echo \"what changed today?\" | skydive chat -p --agent grace", "Read the prompt from stdin").example("skydive chat --agent grace", "Open Grace's conversation list in the TUI (skips the agent picker)").example("skydive chat --resume 0197e0f3-…", "Reopen a previous conversation in the TUI (the id is printed when you quit a chat)"),
1874
+ }).example("skydive chat -p \"summarize my open PRs\" --agent grace", "One-shot, non-interactive").example("echo \"what changed today?\" | skydive chat -p --agent grace", "Read the prompt from stdin").example("skydive chat --agent grace", "Open Grace's conversation list in the TUI (skips the agent picker)").example("skydive chat --agent grace --new", "Start a fresh conversation with Grace directly (skips the conversation list)").example("skydive chat --resume 0197e0f3-…", "Reopen a previous conversation in the TUI (the id is printed when you quit a chat)"),
1479
1875
  handler: async (argv) => {
1480
1876
  const appUrl = resolveAppUrl({ appUrl: argv["api-url"] });
1481
1877
  if (argv.print !== void 0) {
@@ -1511,7 +1907,12 @@ const chatCommand = {
1511
1907
  printError(`Unknown theme "${themeId}". Valid themes: ${themes.map((t) => t.id).join(", ")}`);
1512
1908
  process.exit(1);
1513
1909
  }
1514
- const { runChat } = await import("./boot-CGaXUEer.mjs");
1910
+ await ensureAgentWorkspace({
1911
+ appUrl,
1912
+ sessionToken: session.value.sessionToken,
1913
+ agentSelector: argv.agent ?? null
1914
+ });
1915
+ const { runChat } = await import("./boot-HAgDg_b4.mjs");
1515
1916
  await runChat({
1516
1917
  appUrl,
1517
1918
  sessionToken: session.value.sessionToken,
@@ -1521,6 +1922,7 @@ const chatCommand = {
1521
1922
  notifications: argv.notify,
1522
1923
  agentSelector: argv.agent ?? null,
1523
1924
  conversationId: argv.conversation ?? null,
1925
+ newConversation: resolveNewConversation(argv),
1524
1926
  seedPrompt: null
1525
1927
  });
1526
1928
  }
@@ -1533,13 +1935,55 @@ const chatCommand = {
1533
1935
  function resolveShareMachine(argv) {
1534
1936
  return argv["share-machine"] ?? getShareMachineDefault();
1535
1937
  }
1938
+ /**
1939
+ * Whether this invocation should boot straight into a fresh conversation:
1940
+ * `--new` requests it, but `--resume <id>` always wins (it pins an existing
1941
+ * conversation, so "new" is meaningless alongside it).
1942
+ */
1943
+ function resolveNewConversation(argv) {
1944
+ return argv.new === true && !argv.conversation;
1945
+ }
1946
+ /**
1947
+ * Cross-workspace `--agent <uuid>` handling shared by the TUI and -p paths:
1948
+ * on a hit in another workspace, switch to it and say so on stderr (stdout
1949
+ * may carry the -p reply/JSON envelope); when the id matches no workspace on
1950
+ * the account, fail with the two realistic fixes instead of the misleading
1951
+ * "no agent matches" roster error.
1952
+ */
1953
+ async function ensureAgentWorkspace({ appUrl, sessionToken, agentSelector }) {
1954
+ const resolution = await resolveAgentWorkspace({
1955
+ appUrl,
1956
+ sessionToken,
1957
+ agentSelector
1958
+ });
1959
+ if (resolution.kind === "active") return;
1960
+ if (resolution.kind === "switched") {
1961
+ console.error(`Switched workspace to ${resolution.workspace.name} (${resolution.workspace.slug}) — ${resolution.agentName} lives there.`);
1962
+ return;
1963
+ }
1964
+ if (resolution.kind === "list-failed") {
1965
+ printError(`No agent with id ${agentSelector} in the current workspace, and listing your other workspaces failed (${resolution.message}), so it couldn't be searched for elsewhere. Retry, or run \`skydive workspace switch <slug>\` if you know where it lives.`);
1966
+ process.exit(1);
1967
+ }
1968
+ if (resolution.kind === "switch-failed") {
1969
+ printError(`Agent found in workspace ${resolution.workspace.name}, but switching to it failed: ${resolution.message}. Run \`skydive workspace switch ${resolution.workspace.slug}\` and retry.`);
1970
+ process.exit(1);
1971
+ }
1972
+ printError(`No agent with id ${agentSelector} in any workspace on this account. It may belong to a different account — run \`skydive auth login\` with the right one — or the agent may have been archived.`);
1973
+ process.exit(1);
1974
+ }
1536
1975
  async function runPrintMode({ argv, appUrl }) {
1537
1976
  const session = resolveSession({ appUrl });
1538
1977
  if (session.isErr()) {
1539
1978
  printError(`${session.error.message} For non-interactive use, run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
1540
1979
  process.exit(1);
1541
1980
  }
1542
- const { runPrint, readStdin } = await import("./print-BZH0b3KB.mjs");
1981
+ const { runPrint, readStdin } = await import("./print-CPCFci60.mjs");
1982
+ await ensureAgentWorkspace({
1983
+ appUrl,
1984
+ sessionToken: session.value.sessionToken,
1985
+ agentSelector: argv.agent ?? null
1986
+ });
1543
1987
  let prompt = (argv.print ?? "").trim();
1544
1988
  if (!prompt) {
1545
1989
  if (process.stdin.isTTY) {
@@ -1554,7 +1998,7 @@ async function runPrintMode({ argv, appUrl }) {
1554
1998
  }
1555
1999
  let machineShare = null;
1556
2000
  if (resolveShareMachine(argv)) {
1557
- const { connectMachineShare } = await import("./print-share-D7OSxvE2.mjs");
2001
+ const { connectMachineShare } = await import("./print-share-7qfpQtWK.mjs");
1558
2002
  machineShare = await connectMachineShare({
1559
2003
  appUrl,
1560
2004
  sessionToken: session.value.sessionToken,
@@ -1613,7 +2057,7 @@ const getCommand = {
1613
2057
  printError(`${session.error.message} Run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
1614
2058
  process.exit(1);
1615
2059
  }
1616
- const { messageGet } = await import("./print-BZH0b3KB.mjs");
2060
+ const { messageGet } = await import("./print-CPCFci60.mjs");
1617
2061
  try {
1618
2062
  const result = await messageGet({
1619
2063
  appUrl,
@@ -1810,7 +2254,7 @@ const switchCommand = {
1810
2254
  printError("The workspace picker needs the Bun runtime and it could not be set up automatically. Pass a workspace slug instead, or install Bun and retry.");
1811
2255
  process.exit(1);
1812
2256
  }
1813
- const { runWorkspacePicker } = await import("./boot-CGaXUEer.mjs");
2257
+ const { runWorkspacePicker } = await import("./boot-HAgDg_b4.mjs");
1814
2258
  await runWorkspacePicker(session);
1815
2259
  return;
1816
2260
  }
@@ -1888,7 +2332,7 @@ const openCommand = {
1888
2332
  const agent = argv.agent ? resolveAgent((await fetchPortalDevices(session)).agents, argv.agent) : null;
1889
2333
  const cwd = argv.cwd ? path.resolve(argv.cwd) : process.cwd();
1890
2334
  const { machineName } = machineIdentity();
1891
- const { PortalClient } = await import("./client-D6NAkL9e.mjs");
2335
+ const { PortalClient } = await import("./client-DuwxEDG4.mjs");
1892
2336
  let lastLine = "";
1893
2337
  let signalConnected;
1894
2338
  const connected = new Promise((resolve) => {
@@ -1897,7 +2341,7 @@ const openCommand = {
1897
2341
  const client = new PortalClient({
1898
2342
  appUrl: session.appUrl,
1899
2343
  sessionToken: session.sessionToken,
1900
- cwd,
2344
+ resolveCwd: () => cwd,
1901
2345
  onState: (state) => {
1902
2346
  if (state.status === "connected") signalConnected();
1903
2347
  const line = state.status === "error" ? `portal: connection error: ${state.error ?? "unknown"} — retrying` : `portal: ${state.status}`;
@@ -2012,10 +2456,111 @@ function buildDeviceTable(devices, agents, thisMachineName) {
2012
2456
  ])
2013
2457
  };
2014
2458
  }
2459
+ /**
2460
+ * `skydive portal daemon <cmd>` — operator control of the per-host portal daemon
2461
+ * (the process that owns the single portal connection and routes exec per
2462
+ * conversation). Distinct from the agent-facing `platform portal`: this manages
2463
+ * the local daemon a human runs, so it lives on the skydive CLI only.
2464
+ */
2465
+ const daemonStatusCommand = {
2466
+ command: "status",
2467
+ describe: "Show the portal daemon: running state, connection, and routing map",
2468
+ handler: async (argv) => {
2469
+ const status = await queryDaemonStatus(requireSession(argv).appUrl);
2470
+ if (argv.json) {
2471
+ output(argv, status ?? { running: false });
2472
+ return;
2473
+ }
2474
+ if (!status) {
2475
+ console.log("portal daemon: not running");
2476
+ return;
2477
+ }
2478
+ const cwdCount = Object.keys(status.cwds).length;
2479
+ console.log(`portal daemon: running (pid ${status.pid})`);
2480
+ console.log(` app: ${status.appUrl}`);
2481
+ console.log(` connection: ${status.portal.status}${status.portal.error ? ` (${status.portal.error})` : ""}`);
2482
+ if (status.portal.machineName) console.log(` machine: ${status.portal.friendlyName}`);
2483
+ console.log(` attached: ${status.clientCount} CLI session(s)`);
2484
+ console.log(` granted: ${status.portal.grantedAgentIds.length} agent(s)`);
2485
+ console.log(` routing: ${cwdCount} conversation(s)`);
2486
+ }
2487
+ };
2488
+ const daemonStopCommand = {
2489
+ command: "stop",
2490
+ describe: "Stop the portal daemon, cutting every attached session and live exec/tunnel",
2491
+ handler: async (argv) => {
2492
+ const result = await stopDaemon(requireSession(argv).appUrl);
2493
+ if (argv.json) {
2494
+ output(argv, { result });
2495
+ return;
2496
+ }
2497
+ console.log(result === "not-running" ? "portal daemon: not running" : result === "failed" ? "portal daemon: could not be stopped" : `portal daemon: stopped${result === "killed" ? " (force-killed)" : ""}`);
2498
+ }
2499
+ };
2500
+ const daemonStartCommand = {
2501
+ command: "start",
2502
+ describe: "Start the portal daemon if it is not already running",
2503
+ handler: async (argv) => {
2504
+ const session = requireSession(argv);
2505
+ await ensureDaemonRunning(session.appUrl);
2506
+ const status = await queryDaemonStatus(session.appUrl);
2507
+ if (argv.json) {
2508
+ output(argv, {
2509
+ running: !!status,
2510
+ pid: status?.pid ?? null
2511
+ });
2512
+ return;
2513
+ }
2514
+ console.log(status ? `portal daemon: running (pid ${status.pid})` : "portal daemon: failed to start");
2515
+ }
2516
+ };
2517
+ const daemonRestartCommand = {
2518
+ command: "restart",
2519
+ describe: "Stop and restart the portal daemon (e.g. to pick up a new build)",
2520
+ handler: async (argv) => {
2521
+ const session = requireSession(argv);
2522
+ await stopDaemon(session.appUrl);
2523
+ await ensureDaemonRunning(session.appUrl);
2524
+ const status = await queryDaemonStatus(session.appUrl);
2525
+ if (argv.json) {
2526
+ output(argv, {
2527
+ running: !!status,
2528
+ pid: status?.pid ?? null
2529
+ });
2530
+ return;
2531
+ }
2532
+ console.log(status ? `portal daemon: restarted (pid ${status.pid})` : "portal daemon: failed to restart");
2533
+ }
2534
+ };
2535
+ const daemonLogsCommand = {
2536
+ command: "logs",
2537
+ describe: "Print the portal daemon's log",
2538
+ builder: (y) => y.option("follow", {
2539
+ alias: "f",
2540
+ type: "boolean",
2541
+ default: false,
2542
+ describe: "Follow the log (like tail -f)"
2543
+ }),
2544
+ handler: async (argv) => {
2545
+ const { logPath } = daemonPaths(requireSession(argv).appUrl);
2546
+ const child = spawn("tail", argv.follow === true ? ["-f", logPath] : [
2547
+ "-n",
2548
+ "200",
2549
+ logPath
2550
+ ], { stdio: "inherit" });
2551
+ await new Promise((resolve) => child.on("close", () => resolve()));
2552
+ }
2553
+ };
2554
+ const daemonCommand = {
2555
+ command: "daemon",
2556
+ describe: "Control the local portal daemon (status, stop, start, restart, logs)",
2557
+ builder: (y) => y.command(daemonStatusCommand).command(daemonStopCommand).command(daemonStartCommand).command(daemonRestartCommand).command(daemonLogsCommand).demandCommand(1, "Specify a subcommand: status, stop, start, restart, logs"),
2558
+ handler: () => {}
2559
+ };
2015
2560
  const portalCommand = {
2016
2561
  command: "portal",
2017
2562
  describe: "Open this machine's portal to agents and manage their access",
2018
- builder: (y) => y.command(openCommand).command(grantCommand).command(revokeCommand).command(statusCommand).demandCommand(1, "Specify a subcommand: open, grant, revoke, status"),
2563
+ builder: (y) => y.command(openCommand).command(grantCommand).command(revokeCommand).command(statusCommand).command(daemonCommand).demandCommand(1, "Specify a subcommand: open, grant, revoke, status, daemon"),
2019
2564
  handler: () => {}
2020
2565
  };
2021
2566
 
@@ -2060,8 +2605,8 @@ const sandboxCommand = {
2060
2605
  }).example("skydive sandbox --agent grace", "Live terminal (Ctrl-] detaches)").example("skydive sandbox --agent grace -- tail -n 50 /tmp/harness.log", "One-shot command (use `--` so its flags reach the sandbox)").example("skydive sandbox --agent grace -- sh -c 'ls /tmp | wc -l'", "Shell features go through an explicit `sh -c`"),
2061
2606
  handler: async (argv) => {
2062
2607
  const session = requireSession(argv);
2063
- const { createRestClient } = await import("./rest-COkLEZOB.mjs");
2064
- const { resolveAgent } = await import("./print-BZH0b3KB.mjs");
2608
+ const { createRestClient } = await import("./rest-CDTXCmUb.mjs");
2609
+ const { resolveAgent } = await import("./print-CPCFci60.mjs");
2065
2610
  const client = createRestClient({
2066
2611
  appUrl: session.appUrl,
2067
2612
  sessionToken: session.sessionToken
@@ -2130,7 +2675,7 @@ async function runPty({ session, agentId, agentName }) {
2130
2675
  return 1;
2131
2676
  }
2132
2677
  console.error(`Connecting to ${agentName}'s sandbox… (Ctrl-] detaches)`);
2133
- const { runRawPtyPassthrough } = await import("./raw-pty-2_VA1kw_.mjs");
2678
+ const { runRawPtyPassthrough } = await import("./raw-pty-ChUHav4d.mjs");
2134
2679
  const result = await runRawPtyPassthrough({
2135
2680
  stdin: process.stdin,
2136
2681
  stdout: process.stdout,
@@ -2142,10 +2687,679 @@ async function runPty({ session, agentId, agentName }) {
2142
2687
  return result.code;
2143
2688
  }
2144
2689
 
2690
+ //#endregion
2691
+ //#region src/update-check/manifest.ts
2692
+ /**
2693
+ * The release manifest the binary pipeline publishes
2694
+ * (`.github/workflows/release-skydive-cli-binaries.yml`) and the api serves
2695
+ * (`apps/anyone/api/src/routes/cli-releases.ts`). The update *check* only
2696
+ * needs `version` (see ./sources.ts), but the self-updater
2697
+ * (`../commands/update.ts`) needs the per-target byte pointer + integrity
2698
+ * digest too, so the full shape lives here and both consume it.
2699
+ *
2700
+ * Kept deliberately in sync with the api's `manifestSchema`: a field the api
2701
+ * validates but we drop here is fine, but a field we *require* that the api
2702
+ * doesn't emit would make every install fail — so required fields mirror the
2703
+ * api exactly (name, version, channel, per-target key/sha256/size).
2704
+ */
2705
+ const releaseManifestSchema = z.object({
2706
+ name: z.string().min(1),
2707
+ version: z.string().min(1),
2708
+ channel: z.string().min(1),
2709
+ pubDate: z.string().optional(),
2710
+ targets: z.record(z.object({
2711
+ key: z.string().min(1),
2712
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
2713
+ size: z.number().int().positive()
2714
+ }))
2715
+ });
2716
+ /**
2717
+ * The four release targets the pipeline builds. Named `<os>-<arch>` to match
2718
+ * the manifest's `targets` keys and the install script's `$OS-$ARCH`.
2719
+ */
2720
+ const RELEASE_TARGETS = [
2721
+ "darwin-arm64",
2722
+ "darwin-x64",
2723
+ "linux-arm64",
2724
+ "linux-x64"
2725
+ ];
2726
+ function isReleaseTarget(value) {
2727
+ return RELEASE_TARGETS.some((target) => target === value);
2728
+ }
2729
+ /**
2730
+ * Map this host's `process.platform` / `process.arch` to a release target,
2731
+ * or null when we don't publish a binary for it. Mirrors the install
2732
+ * script's `uname -s` / `uname -m` normalization (arm64/aarch64 -> arm64,
2733
+ * x86_64/amd64 -> x64) so a CLI self-update resolves the same object a fresh
2734
+ * `curl | sh` install would.
2735
+ */
2736
+ function hostReleaseTarget(platform, arch) {
2737
+ const os = platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : null;
2738
+ if (!os) return null;
2739
+ const cpu = arch === "arm64" || arch === "aarch64" ? "arm64" : arch === "x64" || arch === "x86_64" || arch === "amd64" ? "x64" : null;
2740
+ if (!cpu) return null;
2741
+ const target = `${os}-${cpu}`;
2742
+ return isReleaseTarget(target) ? target : null;
2743
+ }
2744
+ function lookupTarget(manifest, platform, arch) {
2745
+ const target = hostReleaseTarget(platform, arch);
2746
+ if (!target) return {
2747
+ ok: false,
2748
+ reason: "unsupported-host",
2749
+ target: null
2750
+ };
2751
+ const entry = manifest.targets[target];
2752
+ if (!entry) return {
2753
+ ok: false,
2754
+ reason: "missing-from-manifest",
2755
+ target
2756
+ };
2757
+ return {
2758
+ ok: true,
2759
+ target,
2760
+ entry
2761
+ };
2762
+ }
2763
+
2764
+ //#endregion
2765
+ //#region src/update-check/versions.ts
2766
+ /** Canary builds carry a prerelease suffix (`X.Y.Z-beta.N`, synthesized in
2767
+ * CI); a plain semver is a stable release. An unparseable version defaults
2768
+ * to stable. */
2769
+ function resolveChannel(version) {
2770
+ return (semver.prerelease(version, { loose: true })?.length ?? 0) > 0 ? "canary" : "stable";
2771
+ }
2772
+ /** npm dist-tag for a channel (see release-skydive-cli.yml). */
2773
+ function distTagForChannel(channel) {
2774
+ return channel === "canary" ? "beta" : "latest";
2775
+ }
2776
+ /**
2777
+ * True when `candidate` is a strictly newer release than `current`, per
2778
+ * semver precedence (the `semver` package, including prerelease ordering).
2779
+ * Unparseable input is never newer, so garbage from the registry can't
2780
+ * produce a notice.
2781
+ */
2782
+ function isNewerVersion(candidate, current) {
2783
+ if (!semver.valid(candidate, { loose: true })) return false;
2784
+ if (!semver.valid(current, { loose: true })) return false;
2785
+ return semver.gt(candidate, current, { loose: true });
2786
+ }
2787
+
2788
+ //#endregion
2789
+ //#region src/update-check/install.ts
2790
+ /**
2791
+ * The self-updater for compiled-binary installs. The update *check*
2792
+ * (./index.ts) is notify-only; this is what a user-invoked `skydive update`
2793
+ * (../commands/update.ts) runs to actually replace the binary.
2794
+ *
2795
+ * package-manager installs are never self-mutated here — that's the install
2796
+ * mode's own tool's job, and silently reaching into a global npm/yarn/pnpm
2797
+ * prefix is exactly the footgun the existing notice policy avoids. They still
2798
+ * go through the same {@link planUpdate} version comparison (an install that
2799
+ * skips it has no way to know it is already current); only the *action*
2800
+ * differs — the command prints, or offers to run, the upgrade command.
2801
+ *
2802
+ * The dangerous part is replacing a running executable in place, so the flow
2803
+ * is split: a pure {@link planUpdate} decides *whether* and *what* (testable
2804
+ * with no IO), and {@link downloadAndVerify} / {@link applyBinary} do the IO
2805
+ * with integrity checks and an atomic rename.
2806
+ */
2807
+ /** Cap so a wrong URL or a hung CDN can't wedge `skydive update` forever. */
2808
+ const DOWNLOAD_TIMEOUT_MS = 6e4;
2809
+ /**
2810
+ * The single version comparison every install mode goes through: only a
2811
+ * strictly newer release is an update. `allowSameVersion` (`--force`) and
2812
+ * `allowDowngrade` (rollback) are the two explicit escape hatches.
2813
+ */
2814
+ function wantsInstall(opts) {
2815
+ const newer = isNewerVersion(opts.latestVersion, opts.currentVersion);
2816
+ const same = opts.latestVersion === opts.currentVersion;
2817
+ return newer || same && opts.allowSameVersion === true || !newer && !same && opts.allowDowngrade === true;
2818
+ }
2819
+ /**
2820
+ * Decide what `skydive update` should do, given the running version, the
2821
+ * install source, the channel's newest release, and this host. Pure — no
2822
+ * network, no filesystem — so every branch is unit-testable.
2823
+ *
2824
+ * `latestVersion` is the channel's current version for a **package-manager**
2825
+ * install (an npm dist-tag lookup). A binary install reads it off `manifest`
2826
+ * instead, because there the manifest is both the version pointer and the
2827
+ * byte source. Either way it must be a real fetched answer: a package-manager
2828
+ * install that skips the comparison is what made `skydive update` announce
2829
+ * "0.2.0 → 0.2.0" on every invocation.
2830
+ *
2831
+ * `allowSameVersion` forces a reinstall of the current version (the `--force`
2832
+ * escape hatch for a corrupt binary); `allowDowngrade` lets an explicit
2833
+ * pinned/older manifest install over a newer running binary (rollback).
2834
+ */
2835
+ function planUpdate(opts) {
2836
+ const { currentVersion, source, manifest, platform, arch } = opts;
2837
+ if (source !== "binary") {
2838
+ const { latestVersion } = opts;
2839
+ if (!latestVersion || !wantsInstall({
2840
+ ...opts,
2841
+ latestVersion
2842
+ })) return {
2843
+ kind: "up-to-date",
2844
+ currentVersion
2845
+ };
2846
+ return {
2847
+ kind: "unsupported-source",
2848
+ source,
2849
+ latestVersion
2850
+ };
2851
+ }
2852
+ if (!manifest) return {
2853
+ kind: "up-to-date",
2854
+ currentVersion
2855
+ };
2856
+ if (!wantsInstall({
2857
+ ...opts,
2858
+ latestVersion: manifest.version
2859
+ })) return {
2860
+ kind: "up-to-date",
2861
+ currentVersion
2862
+ };
2863
+ const lookup = lookupTarget(manifest, platform, arch);
2864
+ if (!lookup.ok) return lookup.reason === "unsupported-host" ? {
2865
+ kind: "unsupported-host",
2866
+ platform,
2867
+ arch
2868
+ } : {
2869
+ kind: "missing-from-manifest",
2870
+ target: lookup.target,
2871
+ version: manifest.version
2872
+ };
2873
+ return {
2874
+ kind: "update",
2875
+ target: lookup.target,
2876
+ version: manifest.version,
2877
+ key: lookup.entry.key,
2878
+ sha256: lookup.entry.sha256,
2879
+ size: lookup.entry.size
2880
+ };
2881
+ }
2882
+ var UpdateError = class extends Error {};
2883
+ /** Read a Node errno string (`EACCES`, `EROFS`, ...) off an unknown thrown
2884
+ * value without a type assertion — the property is validated before it's read. */
2885
+ function errnoCode(error) {
2886
+ if (error && typeof error === "object" && "code" in error) {
2887
+ const { code } = error;
2888
+ if (typeof code === "string") return code;
2889
+ }
2890
+ }
2891
+ /** Lowercase hex sha256 of a buffer, for comparing against the manifest. */
2892
+ function sha256Hex(bytes) {
2893
+ return createHash("sha256").update(bytes).digest("hex");
2894
+ }
2895
+ /**
2896
+ * Download the release object and verify its digest before it ever touches
2897
+ * the install path. Returns the raw bytes; the caller writes them atomically.
2898
+ * Throws {@link UpdateError} (with an actionable message) on any HTTP,
2899
+ * timeout, or integrity failure — never a bare fetch error.
2900
+ */
2901
+ async function downloadAndVerify(opts) {
2902
+ const url = `${opts.downloadBaseUrl}/${opts.key}`;
2903
+ const doFetch = opts.fetchImpl ?? fetch;
2904
+ const signal = opts.signal ?? AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
2905
+ let response;
2906
+ try {
2907
+ response = await doFetch(url, {
2908
+ signal,
2909
+ redirect: "follow"
2910
+ });
2911
+ } catch (error) {
2912
+ throw new UpdateError(`download failed from ${url}: ${error instanceof Error ? error.message : String(error)}`);
2913
+ }
2914
+ if (!response.ok) throw new UpdateError(`download failed from ${url}: HTTP ${response.status}`);
2915
+ const bytes = new Uint8Array(await response.arrayBuffer());
2916
+ if (bytes.byteLength === 0) throw new UpdateError(`download from ${url} was empty`);
2917
+ const actual = sha256Hex(bytes);
2918
+ if (actual !== opts.expectedSha256) throw new UpdateError(`sha256 mismatch (expected ${opts.expectedSha256}, got ${actual}) — the download may be corrupt; re-run \`skydive update\``);
2919
+ return bytes;
2920
+ }
2921
+ /**
2922
+ * Atomically replace the executable at `destPath` with `bytes`. The temp file
2923
+ * is written in the *same directory* as the target so the final `rename` is
2924
+ * atomic (rename across filesystems is not) — a crash mid-update leaves either
2925
+ * the old binary or the new one, never a truncated file.
2926
+ *
2927
+ * On a locked destination (Windows, or a read-only install dir) the rename
2928
+ * fails; we surface an actionable UpdateError instead of a raw errno. macOS
2929
+ * and Linux happily rename over a running executable (the old inode stays
2930
+ * live for the current process), which is what makes in-place self-update
2931
+ * safe here.
2932
+ */
2933
+ async function applyBinary(destPath, bytes) {
2934
+ const dir = path.dirname(destPath);
2935
+ const tmp = path.join(dir, `.skydive-update-${process.pid}-${Date.now()}.tmp`);
2936
+ try {
2937
+ await fsp.writeFile(tmp, bytes, { mode: 493 });
2938
+ await fsp.chmod(tmp, 493);
2939
+ await fsp.rename(tmp, destPath);
2940
+ } catch (error) {
2941
+ await fsp.rm(tmp, { force: true }).catch(() => {});
2942
+ const code = errnoCode(error);
2943
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") throw new UpdateError(`cannot write ${destPath} (permission denied). Re-run with the right permissions, or reinstall with the install script.`);
2944
+ throw new UpdateError(`failed to install the new binary at ${destPath}: ${error instanceof Error ? error.message : String(error)}`);
2945
+ }
2946
+ }
2947
+ /**
2948
+ * Resolve the path of the currently running executable to overwrite. For a
2949
+ * `bun --compile` binary that's `process.execPath`; we resolve symlinks so a
2950
+ * `~/.local/bin/skydive` symlink into a versioned store updates the real
2951
+ * file, not the link. Returns null when the path can't be determined (e.g. a
2952
+ * non-binary context), which the command treats as "can't self-update".
2953
+ */
2954
+ function resolveSelfPath(execPath = process.execPath) {
2955
+ if (!execPath) return null;
2956
+ try {
2957
+ return fs.realpathSync(execPath);
2958
+ } catch (_error) {
2959
+ return execPath;
2960
+ }
2961
+ }
2962
+
2963
+ //#endregion
2964
+ //#region src/update-check/sources.ts
2965
+ function resolveInstallSource() {
2966
+ return typeof SKYDIVE_CLI_INSTALL_SOURCE === "string" && SKYDIVE_CLI_INSTALL_SOURCE === "binary" ? "binary" : "package-manager";
2967
+ }
2968
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org";
2969
+ const npmDistTagsSchema = z.record(z.string(), z.unknown());
2970
+ const binaryManifestSchema = z.object({ version: z.unknown().optional() });
2971
+ /** npm dist-tags for the published package (`skydive-cli`). */
2972
+ const npmReleaseSource = {
2973
+ async fetchLatestVersion(channel, { signal }) {
2974
+ const response = await fetch(`${NPM_REGISTRY_URL}/-/package/${name}/dist-tags`, {
2975
+ signal,
2976
+ headers: { accept: "application/json" }
2977
+ });
2978
+ if (!response.ok) return null;
2979
+ const tags = npmDistTagsSchema.safeParse(await response.json());
2980
+ if (!tags.success) return null;
2981
+ const version = tags.data[distTagForChannel(channel)];
2982
+ return typeof version === "string" ? version : null;
2983
+ },
2984
+ async fetchManifest() {
2985
+ return null;
2986
+ }
2987
+ };
2988
+ /**
2989
+ * Release CDN (CloudFront over the releases bucket, infra: CliReleasesCdn).
2990
+ * Serves the channel pointers the release workflow uploads
2991
+ * (`channels/{stable,canary}.json`, cached max-age=60) alongside the
2992
+ * binaries. The daily poll goes here, not to the api, so a fleet of
2993
+ * installed binaries puts no load on — and takes no dependency on — the api.
2994
+ */
2995
+ const RELEASE_CDN_URL = "https://dl.skydive.com";
2996
+ /** Channel manifest on the release CDN, for compiled binaries. The same
2997
+ * document the api's channel route serves (see
2998
+ * apps/anyone/api/src/routes/cli-releases.ts, which reads it from the
2999
+ * bucket this CDN fronts). */
3000
+ const binaryReleaseSource = {
3001
+ async fetchLatestVersion(channel, { signal }) {
3002
+ const response = await fetch(`${RELEASE_CDN_URL}/channels/${channel}.json`, {
3003
+ signal,
3004
+ headers: { accept: "application/json" }
3005
+ });
3006
+ if (!response.ok) return null;
3007
+ const manifest = binaryManifestSchema.safeParse(await response.json());
3008
+ if (!manifest.success) return null;
3009
+ return typeof manifest.data.version === "string" ? manifest.data.version : null;
3010
+ },
3011
+ async fetchManifest(channel, { signal }) {
3012
+ const response = await fetch(`${RELEASE_CDN_URL}/channels/${channel}.json`, {
3013
+ signal,
3014
+ headers: { accept: "application/json" }
3015
+ });
3016
+ if (!response.ok) return null;
3017
+ const manifest = releaseManifestSchema.safeParse(await response.json());
3018
+ return manifest.success ? manifest.data : null;
3019
+ }
3020
+ };
3021
+ function releaseSourceForInstall(source) {
3022
+ return source === "binary" ? binaryReleaseSource : npmReleaseSource;
3023
+ }
3024
+
3025
+ //#endregion
3026
+ //#region src/update-check/notice.ts
3027
+ /** The install-mode-specific action line. A package-manager install is never
3028
+ * self-mutated — we only tell the user what to run. A binary install has a
3029
+ * built-in self-updater (`skydive update`, ../commands/update.ts), so that's
3030
+ * the action; the curl re-install stays the documented fallback. */
3031
+ function renderUpdateCommand(channel, source) {
3032
+ if (source === "binary") return channel === "canary" ? "skydive update --channel canary" : "skydive update";
3033
+ return `npm install -g ${name}@${distTagForChannel(channel)}`;
3034
+ }
3035
+ function renderUpdateNotice(opts) {
3036
+ return `\nUpdate available: ${opts.currentVersion} \u2192 ${opts.latestVersion}\nRun ${renderUpdateCommand(opts.channel, opts.source)}\n`;
3037
+ }
3038
+ /**
3039
+ * Whether this invocation may print the notice. Pure so it's testable; the
3040
+ * inputs are raw pre-yargs argv (parsing hasn't happened when this runs) and
3041
+ * stderr's TTY-ness. `--json`/`--quiet` go to stdout, and the notice goes to
3042
+ * stderr — but scripts commonly capture 2>&1, so machine-readable modes
3043
+ * suppress it entirely rather than risk corrupting piped output.
3044
+ */
3045
+ function shouldNotify(opts) {
3046
+ if (!opts.stderrIsTTY) return false;
3047
+ if (opts.argv.includes("--json") || opts.argv.includes("--quiet")) return false;
3048
+ return true;
3049
+ }
3050
+
3051
+ //#endregion
3052
+ //#region src/update-check/package-manager.ts
3053
+ const KNOWN = [
3054
+ "npm",
3055
+ "yarn",
3056
+ "pnpm",
3057
+ "bun"
3058
+ ];
3059
+ function isKnown(value) {
3060
+ return KNOWN.some((m) => m === value);
3061
+ }
3062
+ /**
3063
+ * Parse the manager name out of an `npm_config_user_agent` string. npm, yarn,
3064
+ * pnpm and bun all set it to `"<manager>/<version> ..."` when they spawn a
3065
+ * script, which is the most reliable signal for how *this* process was
3066
+ * launched. Returns null when the var is absent or unrecognized.
3067
+ */
3068
+ function parseUserAgent(userAgent) {
3069
+ if (!userAgent) return null;
3070
+ const name = userAgent.trim().split("/")[0]?.toLowerCase();
3071
+ return name && isKnown(name) ? name : null;
3072
+ }
3073
+ /**
3074
+ * Best-effort detection of the manager that owns this install. Order of
3075
+ * signals, most to least reliable:
3076
+ * 1. `npm_config_user_agent` — set by the manager that spawned us.
3077
+ * 2. the executable path — a global bin under a `pnpm`/`yarn`/`bun` store
3078
+ * names the manager even when we weren't spawned by it (e.g. the user
3079
+ * typed `skydive` directly).
3080
+ * Falls back to `npm`, the documented default install path, so the command we
3081
+ * show is always runnable — the prompt still lets the user decline and copy a
3082
+ * different one.
3083
+ */
3084
+ function detectPackageManager(opts) {
3085
+ const fromUa = parseUserAgent(opts.env["npm_config_user_agent"]);
3086
+ if (fromUa) return fromUa;
3087
+ const exec = opts.execPath.toLowerCase();
3088
+ if (exec.includes("pnpm")) return "pnpm";
3089
+ if (exec.includes("yarn")) return "yarn";
3090
+ if (exec.includes("bun")) return "bun";
3091
+ return "npm";
3092
+ }
3093
+ /**
3094
+ * The global-install command (as argv) for a manager + channel. Each manager's
3095
+ * global-add spelling differs; the version target is the channel's dist-tag
3096
+ * (`latest`/`beta`), matching what the release workflow publishes.
3097
+ */
3098
+ function installCommand(manager, channel) {
3099
+ const spec = `${name}@${distTagForChannel(channel)}`;
3100
+ switch (manager) {
3101
+ case "npm": return [
3102
+ "npm",
3103
+ "install",
3104
+ "-g",
3105
+ spec
3106
+ ];
3107
+ case "pnpm": return [
3108
+ "pnpm",
3109
+ "add",
3110
+ "-g",
3111
+ spec
3112
+ ];
3113
+ case "bun": return [
3114
+ "bun",
3115
+ "add",
3116
+ "-g",
3117
+ spec
3118
+ ];
3119
+ case "yarn": return [
3120
+ "yarn",
3121
+ "global",
3122
+ "add",
3123
+ spec
3124
+ ];
3125
+ }
3126
+ }
3127
+ /** The command as a copy-pasteable string for prompts and notices. */
3128
+ function installCommandString(manager, channel) {
3129
+ return installCommand(manager, channel).join(" ");
3130
+ }
3131
+
3132
+ //#endregion
3133
+ //#region src/update-check/pm-update.ts
3134
+ /**
3135
+ * The interactive upgrade path for a package-manager install: instead of only
3136
+ * printing "run npm install -g …", offer to run it now, and on confirm run
3137
+ * that exact command and relaunch the CLI on the new version.
3138
+ *
3139
+ * We never silently mutate a package-manager install (the manager owns it),
3140
+ * but an explicit prompt the user answers with a keypress is honest consent —
3141
+ * and it runs the *same* command we detected + display, so what you approve is
3142
+ * what executes. Non-interactive contexts (no TTY, --json/--quiet, CI) skip
3143
+ * the prompt entirely and fall back to the printed notice.
3144
+ */
3145
+ /** Whether it's safe to show an interactive prompt at all. */
3146
+ function canPromptInteractively(opts) {
3147
+ if (opts.json || opts.quiet) return false;
3148
+ if (!opts.stdinIsTTY || !opts.stdoutIsTTY) return false;
3149
+ if (opts.env["CI"] || opts.env["SKYDIVE_NO_UPDATE_CHECK"]) return false;
3150
+ return true;
3151
+ }
3152
+ /**
3153
+ * Interpret a raw prompt answer. Default (empty / Enter) is **yes** — the
3154
+ * prompt renders `[Y/n]`, so a bare Enter accepts. Anything starting with `n`
3155
+ * (case-insensitive) is no; any other non-empty input is treated as no too,
3156
+ * so a fat-fingered answer never runs an install the user didn't mean.
3157
+ */
3158
+ function interpretYesNo(answer) {
3159
+ const a = answer.trim().toLowerCase();
3160
+ if (a === "") return true;
3161
+ return a === "y" || a === "yes";
3162
+ }
3163
+ /** Ask a `[Y/n]` question on the TTY and resolve to the boolean answer. */
3164
+ async function promptYesNo(question) {
3165
+ const rl = createInterface({
3166
+ input: process.stdin,
3167
+ output: process.stdout
3168
+ });
3169
+ try {
3170
+ return interpretYesNo(await new Promise((resolve) => {
3171
+ rl.question(question, resolve);
3172
+ }));
3173
+ } finally {
3174
+ rl.close();
3175
+ }
3176
+ }
3177
+ /**
3178
+ * Heading shared by the prompt and the printed instruction, so the two can't
3179
+ * disagree about what is happening. `--force` asks to reinstall the version
3180
+ * already running, and calling that an update is exactly the lie this path
3181
+ * used to tell on every invocation — so it gets its own wording.
3182
+ */
3183
+ function renderPmUpdateHeading(opts) {
3184
+ return opts.latestVersion === opts.currentVersion ? `Reinstalling ${opts.currentVersion}.` : `Update available: ${opts.currentVersion} \u2192 ${opts.latestVersion}`;
3185
+ }
3186
+ /**
3187
+ * Offer + run the package-manager upgrade. Returns a result the caller turns
3188
+ * into output and an exit/relaunch decision — kept separate from the actual
3189
+ * relaunch so it's testable without re-execing the test runner.
3190
+ */
3191
+ async function offerPackageManagerUpdate(opts) {
3192
+ const manager = detectPackageManager({
3193
+ env: opts.env,
3194
+ execPath: opts.execPath
3195
+ });
3196
+ const command = installCommand(manager, opts.channel);
3197
+ const commandStr = installCommandString(manager, opts.channel);
3198
+ if (!opts.interactive) return { status: "skipped-noninteractive" };
3199
+ if (!await (opts.ask ?? promptYesNo)(`${renderPmUpdateHeading(opts)}\nRun \`${commandStr}\` now? [Y/n] `)) return { status: "declined" };
3200
+ const { status } = (opts.run ?? ((cmd) => {
3201
+ const [bin, ...args] = cmd;
3202
+ return { status: spawnSync(bin ?? "", args, { stdio: "inherit" }).status };
3203
+ }))(command);
3204
+ if (status !== 0) return {
3205
+ status: "failed",
3206
+ code: status,
3207
+ command: commandStr
3208
+ };
3209
+ return {
3210
+ status: "installed",
3211
+ command: commandStr
3212
+ };
3213
+ }
3214
+ /**
3215
+ * Relaunch the CLI after a successful in-place upgrade so the user lands back
3216
+ * on their command running the new version. Uses the same interpreter + argv
3217
+ * the current process was launched with; detaches so the parent can exit
3218
+ * cleanly. Never throws — a failed relaunch just means the user re-runs
3219
+ * manually, which the caller's message covers.
3220
+ */
3221
+ function relaunchCli(argv = process.argv) {
3222
+ try {
3223
+ const [, script, ...rest] = argv;
3224
+ spawn(process.execPath, script ? [script, ...rest] : rest, { stdio: "inherit" }).on("exit", (code) => process.exit(code ?? 0));
3225
+ } catch (_error) {
3226
+ process.exit(0);
3227
+ }
3228
+ }
3229
+
3230
+ //#endregion
3231
+ //#region src/commands/update.ts
3232
+ /**
3233
+ * `skydive update` — the user-invoked self-updater the update *check*
3234
+ * (../update-check/index.ts) has always pointed at. Notify-only remains the
3235
+ * automatic behavior; this is the explicit "do it now" action.
3236
+ *
3237
+ * Binary installs self-update in place (download -> verify sha256 -> atomic
3238
+ * replace); package-manager installs are never self-mutated (the notice
3239
+ * policy) — we print the exact upgrade command instead. `--check` is a dry
3240
+ * run; `--force` reinstalls the current version (repair a corrupt binary).
3241
+ */
3242
+ const FETCH_TIMEOUT_MS$1 = 15e3;
3243
+ function normalizeChannel(raw, currentVersion) {
3244
+ if (raw === "stable" || raw === "canary") return raw;
3245
+ return resolveChannel(currentVersion);
3246
+ }
3247
+ /** Human-readable line for a plan outcome. Pure so the handler stays thin. */
3248
+ function describePlan(plan, channel, isCheck) {
3249
+ switch (plan.kind) {
3250
+ case "up-to-date": return `Already on the latest ${channel} release (${plan.currentVersion}).`;
3251
+ case "unsupported-source": return `${renderPmUpdateHeading({
3252
+ currentVersion: version,
3253
+ latestVersion: plan.latestVersion
3254
+ })}\nThis is a ${plan.source} install, so update through your package manager:\n ${renderUpdateCommand(channel, plan.source)}`;
3255
+ case "unsupported-host": return `No Skydive binary is published for ${plan.platform}/${plan.arch}.`;
3256
+ case "missing-from-manifest": return `The ${channel} ${plan.version} release has no binary for your platform (${plan.target}).`;
3257
+ case "update": return isCheck ? `Update available: ${version} \u2192 ${plan.version} (${channel}, ${plan.target}).\nRun \`skydive update\` to install it.` : `Updating ${version} \u2192 ${plan.version} (${channel}, ${plan.target})...`;
3258
+ }
3259
+ }
3260
+ const updateCommand = {
3261
+ command: "update",
3262
+ describe: "Update the Skydive CLI to the latest release",
3263
+ builder: (y) => y.option("channel", {
3264
+ type: "string",
3265
+ choices: ["stable", "canary"],
3266
+ describe: "Release channel to update to (default: this build's channel)"
3267
+ }).option("check", {
3268
+ type: "boolean",
3269
+ default: false,
3270
+ describe: "Only report whether an update is available; don't install"
3271
+ }).option("force", {
3272
+ type: "boolean",
3273
+ default: false,
3274
+ describe: "Reinstall the current version even if already up to date (repairs a corrupt binary)"
3275
+ }).example("skydive update", "Install the latest release").example("skydive update --check", "See if an update is available").example("skydive update --channel canary", "Switch to the canary line"),
3276
+ handler: async (argv) => {
3277
+ const currentVersion = version;
3278
+ const source = resolveInstallSource();
3279
+ const channel = normalizeChannel(argv.channel, currentVersion);
3280
+ const releaseSource = releaseSourceForInstall(source);
3281
+ const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS$1);
3282
+ const manifest = source === "binary" ? await releaseSource.fetchManifest(channel, { signal }).catch(() => null) : null;
3283
+ const latestVersion = source === "binary" ? manifest?.version ?? null : await releaseSource.fetchLatestVersion(channel, { signal }).catch(() => null);
3284
+ if (latestVersion === null) {
3285
+ if (argv.json) {
3286
+ output(argv, {
3287
+ status: "unavailable",
3288
+ channel,
3289
+ currentVersion
3290
+ });
3291
+ return;
3292
+ }
3293
+ throw new UpdateError(source === "binary" ? `couldn't reach the ${channel} release channel — check your connection and try again.` : `couldn't reach the npm registry to look up the ${channel} release — check your connection and try again.`);
3294
+ }
3295
+ const plan = planUpdate({
3296
+ currentVersion,
3297
+ source,
3298
+ latestVersion,
3299
+ manifest,
3300
+ platform: process.platform,
3301
+ arch: process.arch,
3302
+ allowSameVersion: argv.force
3303
+ });
3304
+ if (argv.json) {
3305
+ output(argv, {
3306
+ plan,
3307
+ channel,
3308
+ currentVersion,
3309
+ dryRun: argv.check
3310
+ });
3311
+ return;
3312
+ }
3313
+ if (plan.kind === "unsupported-source" && !argv.check) {
3314
+ const interactive = canPromptInteractively({
3315
+ stdinIsTTY: Boolean(process.stdin.isTTY),
3316
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
3317
+ json: argv.json,
3318
+ quiet: argv.quiet,
3319
+ env: process.env
3320
+ });
3321
+ const result = await offerPackageManagerUpdate({
3322
+ channel,
3323
+ currentVersion,
3324
+ latestVersion: plan.latestVersion,
3325
+ env: process.env,
3326
+ execPath: process.execPath,
3327
+ interactive
3328
+ });
3329
+ switch (result.status) {
3330
+ case "installed":
3331
+ console.log(`Updated via \`${result.command}\`. Relaunching \`skydive\`...`);
3332
+ relaunchCli();
3333
+ return;
3334
+ case "failed": throw new UpdateError(`\`${result.command}\` exited with code ${result.code ?? "unknown"}. Try running it yourself.`);
3335
+ case "declined":
3336
+ console.log(describePlan(plan, channel, false));
3337
+ return;
3338
+ case "skipped-noninteractive":
3339
+ console.log(describePlan(plan, channel, false));
3340
+ return;
3341
+ }
3342
+ }
3343
+ if (plan.kind !== "update" || argv.check) {
3344
+ console.log(describePlan(plan, channel, argv.check));
3345
+ return;
3346
+ }
3347
+ const selfPath = resolveSelfPath();
3348
+ if (!selfPath) throw new UpdateError("couldn't determine this binary's path to replace it — reinstall with the install script instead.");
3349
+ console.log(describePlan(plan, channel, false));
3350
+ await applyBinary(selfPath, await downloadAndVerify({
3351
+ downloadBaseUrl: RELEASE_CDN_URL,
3352
+ key: plan.key,
3353
+ expectedSha256: plan.sha256
3354
+ }));
3355
+ console.log(`Updated to ${plan.version}. Run \`skydive --version\` to confirm.`);
3356
+ }
3357
+ };
3358
+
2145
3359
  //#endregion
2146
3360
  //#region src/cli.ts
2147
3361
  function createCli(argv) {
2148
- return yargs(argv).scriptName("skydive").usage("$0 <command> [options]").parserConfiguration({ "populate--": true }).example("skydive auth login", "Store your API key").example("skydive chat", "Open the interactive chat TUI (prod)").example("skydive chat --api-url http://localhost:4500", "Chat against a local dev API").example("skydive agents list", "List your agents").example("skydive keys create \"my-key\"", "Create an API key").example("skydive secrets set OPENAI_API_KEY --agent-id <id>", "Set a secret (value from stdin)").example("skydive workspace list", "List workspaces on your account").example("skydive workspace switch acme-team", "Switch the workspace all `skydive` commands act on").example("skydive portal open --agent grace", "Open the portal to this machine for an agent, headless (no TUI)").option("json", {
3362
+ return yargs(argv).scriptName("skydive").usage("$0 <command> [options]").parserConfiguration({ "populate--": true }).example("skydive auth login", "Store your API key").example("skydive chat", "Open the interactive chat TUI (prod)").example("skydive chat --api-url http://localhost:4500", "Chat against a local dev API").example("skydive agents list", "List your agents").example("skydive completion install", "Set up TAB completion for your shell").example("skydive keys create \"my-key\"", "Create an API key").example("skydive secrets set OPENAI_API_KEY --agent-id <id>", "Set a secret (value from stdin)").example("skydive workspace list", "List workspaces on your account").example("skydive workspace switch acme-team", "Switch the workspace all `skydive` commands act on").example("skydive portal open --agent grace", "Open the portal to this machine for an agent, headless (no TUI)").option("json", {
2149
3363
  type: "boolean",
2150
3364
  default: false,
2151
3365
  global: true,
@@ -2159,7 +3373,7 @@ function createCli(argv) {
2159
3373
  type: "string",
2160
3374
  global: true,
2161
3375
  describe: "Override API base URL"
2162
- }).command(authCommand).command(chatCommand).command(messagesCommand).command(conversationsCommand).command(agentsCommand).command(importCommand).command(keysCommand).command(secretsCommand).command(workspaceCommand).command(portalCommand).command(sandboxCommand).demandCommand(1, "Specify a command. Run --help for usage.").strict().wrap(null).version(version).alias("v", "version").alias("h", "help").help().fail((msg, err) => {
3376
+ }).command(authCommand).command(chatCommand).command(messagesCommand).command(conversationsCommand).command(agentsCommand).command(importCommand).command(keysCommand).command(secretsCommand).command(workspaceCommand).command(portalCommand).command(sandboxCommand).command(updateCommand).command(completionCommand).demandCommand(1, "Specify a command. Run --help for usage.").strict().wrap(null).version(version).alias("v", "version").alias("h", "help").help().fail((msg, err) => {
2163
3377
  printError(err ? err instanceof Error ? err.message : String(err) : msg ?? "Unknown error");
2164
3378
  process.exit(1);
2165
3379
  });
@@ -2271,100 +3485,6 @@ function isCheckDue(lastCheckedAt, now = Date.now()) {
2271
3485
  return now - then >= CHECK_INTERVAL_MS;
2272
3486
  }
2273
3487
 
2274
- //#endregion
2275
- //#region src/update-check/versions.ts
2276
- /** Canary builds carry a prerelease suffix (`X.Y.Z-beta.N`, synthesized in
2277
- * CI); a plain semver is a stable release. An unparseable version defaults
2278
- * to stable. */
2279
- function resolveChannel(version) {
2280
- return (semver.prerelease(version, { loose: true })?.length ?? 0) > 0 ? "canary" : "stable";
2281
- }
2282
- /** npm dist-tag for a channel (see release-skydive-cli.yml). */
2283
- function distTagForChannel(channel) {
2284
- return channel === "canary" ? "beta" : "latest";
2285
- }
2286
- /**
2287
- * True when `candidate` is a strictly newer release than `current`, per
2288
- * semver precedence (the `semver` package, including prerelease ordering).
2289
- * Unparseable input is never newer, so garbage from the registry can't
2290
- * produce a notice.
2291
- */
2292
- function isNewerVersion(candidate, current) {
2293
- if (!semver.valid(candidate, { loose: true })) return false;
2294
- if (!semver.valid(current, { loose: true })) return false;
2295
- return semver.gt(candidate, current, { loose: true });
2296
- }
2297
-
2298
- //#endregion
2299
- //#region src/update-check/notice.ts
2300
- /** The install-mode-specific action line. A package-manager install is never
2301
- * self-mutated — we only tell the user what to run. */
2302
- function renderUpdateCommand(channel, source) {
2303
- if (source === "binary") return `curl -fsSL ${DEFAULT_WEB_URL}/api/v1/cli/install.sh | ${channel === "canary" ? "SKYDIVE_CHANNEL=canary " : ""}sh`;
2304
- return `npm install -g ${name}@${distTagForChannel(channel)}`;
2305
- }
2306
- function renderUpdateNotice(opts) {
2307
- return `\nUpdate available: ${opts.currentVersion} \u2192 ${opts.latestVersion}\nRun ${renderUpdateCommand(opts.channel, opts.source)}\n`;
2308
- }
2309
- /**
2310
- * Whether this invocation may print the notice. Pure so it's testable; the
2311
- * inputs are raw pre-yargs argv (parsing hasn't happened when this runs) and
2312
- * stderr's TTY-ness. `--json`/`--quiet` go to stdout, and the notice goes to
2313
- * stderr — but scripts commonly capture 2>&1, so machine-readable modes
2314
- * suppress it entirely rather than risk corrupting piped output.
2315
- */
2316
- function shouldNotify(opts) {
2317
- if (!opts.stderrIsTTY) return false;
2318
- if (opts.argv.includes("--json") || opts.argv.includes("--quiet")) return false;
2319
- return true;
2320
- }
2321
-
2322
- //#endregion
2323
- //#region src/update-check/sources.ts
2324
- function resolveInstallSource() {
2325
- return typeof SKYDIVE_CLI_INSTALL_SOURCE === "string" && SKYDIVE_CLI_INSTALL_SOURCE === "binary" ? "binary" : "package-manager";
2326
- }
2327
- const NPM_REGISTRY_URL = "https://registry.npmjs.org";
2328
- const npmDistTagsSchema = z.record(z.string(), z.unknown());
2329
- const binaryManifestSchema = z.object({ version: z.unknown().optional() });
2330
- /** npm dist-tags for the published package (`skydive-cli`). */
2331
- const npmReleaseSource = { async fetchLatestVersion(channel, { signal }) {
2332
- const response = await fetch(`${NPM_REGISTRY_URL}/-/package/${name}/dist-tags`, {
2333
- signal,
2334
- headers: { accept: "application/json" }
2335
- });
2336
- if (!response.ok) return null;
2337
- const tags = npmDistTagsSchema.safeParse(await response.json());
2338
- if (!tags.success) return null;
2339
- const version = tags.data[distTagForChannel(channel)];
2340
- return typeof version === "string" ? version : null;
2341
- } };
2342
- /**
2343
- * Release CDN (CloudFront over the releases bucket, infra: CliReleasesCdn).
2344
- * Serves the channel pointers the release workflow uploads
2345
- * (`channels/{stable,canary}.json`, cached max-age=60) alongside the
2346
- * binaries. The daily poll goes here, not to the api, so a fleet of
2347
- * installed binaries puts no load on — and takes no dependency on — the api.
2348
- */
2349
- const RELEASE_CDN_URL = "https://dl.skydive.com";
2350
- /** Channel manifest on the release CDN, for compiled binaries. The same
2351
- * document the api's channel route serves (see
2352
- * apps/anyone/api/src/routes/cli-releases.ts, which reads it from the
2353
- * bucket this CDN fronts). */
2354
- const binaryReleaseSource = { async fetchLatestVersion(channel, { signal }) {
2355
- const response = await fetch(`${RELEASE_CDN_URL}/channels/${channel}.json`, {
2356
- signal,
2357
- headers: { accept: "application/json" }
2358
- });
2359
- if (!response.ok) return null;
2360
- const manifest = binaryManifestSchema.safeParse(await response.json());
2361
- if (!manifest.success) return null;
2362
- return typeof manifest.data.version === "string" ? manifest.data.version : null;
2363
- } };
2364
- function releaseSourceForInstall(source) {
2365
- return source === "binary" ? binaryReleaseSource : npmReleaseSource;
2366
- }
2367
-
2368
3488
  //#endregion
2369
3489
  //#region src/update-check/index.ts
2370
3490
  /**
@@ -2466,7 +3586,7 @@ function setupUpdateCheck(argv) {
2466
3586
 
2467
3587
  //#endregion
2468
3588
  //#region src/changelog.generated.ts
2469
- const CHANGELOG_MD = "# Changelog\n\nAll notable changes to the Skydive CLI are documented here.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [0.2.0] - 2026-07-29\n\n### Added\n\n**Sandbox & machine access**\n\n- Standalone `skydive sandbox` command for direct access to an agent's sandbox.\n- `/sandbox` in the chat TUI — live PTY session and one-shot command execution.\n- Headless machine sharing via `skydive portal`, with self-registering portal grants.\n- `shareMachineDefault` config key for always-on portal sharing.\n\n**Chat TUI**\n\n- File pane (`ctrl+g`) with Changes and Files tabs: a shared file tree plus source viewer and a workspace browser, mouse-resizable and responsive to terminal size.\n- Agent todo list rendered in the chat TUI, above the composer, mirroring the web chat's todo card.\n- Fuzzy finder in the conversation and agent pickers.\n- Slash-command autocomplete menu.\n- Paste or drag-and-drop any file into the composer; paste clipboard images with Cmd+V.\n- Run local shell commands with `!` in the composer.\n- Conversation recaps, streamed live title updates, and per-agent attribution on assistant turns.\n- Working timer rolls up into minutes and hours.\n- Conversation picker paginates past 50 conversations and shows only your own conversations.\n- Esc leaves a live run; typing `exit` quits the chat.\n- Cursor Dark theme.\n- Picker rows no longer overlap when a conversation title contains a newline, tab, or control character.\n\n**Headless & scripting**\n\n- Resume a conversation by id.\n- `conversations list` and `conversations show` for transcript reads.\n- `messages get`, with run recovery keyed on message id.\n- Connect cards surface in headless `-p` mode so a driving agent never gets stuck.\n\n**Authentication**\n\n- `skydive auth login` via the browser now auto-mints an API key, so one login yields both a chat session and a usable management credential. Management commands announce the key's pinned workspace when it drives them, so a workspace mismatch is visible at use time.\n- Workspace picker on the device authorization page.\n- Account and workspace identity shown in `auth status`.\n\n**Platform**\n\n- Standalone binary builds compiling the CLI into a per-target executable.\n- Interactive workspace switcher; management commands follow the active workspace.\n- Terminal host integrations and agent notifications.\n\n### Fixed\n\n- Transcript errors collapse to one line, click to expand (REST and portal errors keep their full body).\n- Dragged/pasted image file paths attach the file instead of inserting path text, including macOS paths with literal parentheses.\n- Bare URLs in chat markdown are hyperlinked so they survive text wrap.\n- Composer draft is preserved across TUI overlays.\n- Relative connect links resolve before opening the browser.\n- Numbered markdown headings render colored in the TUI.\n- Chat transcript pages by 75% of a screen; picker rows stay on one line.\n- Run starts push to the TUI over the conversation stream.\n- Security: remediated high-severity dependency findings and cleared tar/shell-quote CVEs.\n\n## [0.1.0] - 2026-07-21\n\nInitial public release: `skydive chat` TUI, agent and conversation management,\ndevice authorization, and headless `-p` mode.\n";
3589
+ const CHANGELOG_MD = "# Changelog\n\nAll notable changes to the Skydive CLI are documented here.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n## [0.3.0] - 2026-08-04\n\n### Added\n\n**Chat TUI**\n\n- `/fork` slash command to fork a conversation at any point and continue in a new thread.\n- `/conversation` switches threads from inside a live session.\n- `/compact` triggers manual conversation compaction.\n- `/portal` and `/copy` slash commands.\n- Archive the current conversation from the chat TUI.\n- `--new` flag on `skydive chat` to start a fresh conversation.\n- Debounced server-side conversation search in the picker, with title matches ranked first.\n- File review pane orientation toggle, and review comments in the file pane.\n- Pinned plan card controls and a jump-to-latest hint.\n- Terminal title (OSC) follows the live conversation title on every screen; inside cmux the workspace is renamed too, and linked PRs are pushed to the cmux sidebar.\n- Every participating agent shows in the conversation list; connect cards each have their own identity, and ctrl+r asks which connect card to act on.\n- Colored Skydive splash art in `skydive --help` and the TUI pickers.\n- New-agent name prefilled from the server's suggestion.\n\n**CLI & platform**\n\n- `skydive update` self-updater, with an update notice when a new version is available.\n- Shell completions for bash, zsh, and fish via `completion install`.\n- Portal daemon: one connection per host with per-conversation working directories.\n- Agent-led coding-agent import (`skydive import`).\n- Per-workspace scoping on the REST client, and cross-org `--agent <uuid>` auto-switches workspace.\n- `skydive-beta` wrapper for the canary channel.\n\n### Fixed\n\n- `skydive update` no longer claims an update is available on every npm install.\n- Chat memory is bounded (payload clamps + windowed rows) with a memory watchdog and OOM diagnostic report; transcript rows are memoized so streaming stops re-rendering the whole chat.\n- Chat-send failures map to real error messages.\n- Notification and sidebar previews use the last text block of the reply and strip inline markdown.\n- Picker rows no longer overlap when a conversation title contains a newline, tab, or control character.\n- Sending to non-web channel conversations is blocked in the TUI.\n- GitHub bot connect opens on the web front door, not api.skydive.com.\n- TUI activity counter times from the run, not the screen.\n\n## [0.2.0] - 2026-07-29\n\n### Added\n\n**Sandbox & machine access**\n\n- Standalone `skydive sandbox` command for direct access to an agent's sandbox.\n- `/sandbox` in the chat TUI — live PTY session and one-shot command execution.\n- Headless machine sharing via `skydive portal`, with self-registering portal grants.\n- `shareMachineDefault` config key for always-on portal sharing.\n\n**Chat TUI**\n\n- File pane (`ctrl+g`) with Changes and Files tabs: a shared file tree plus source viewer and a workspace browser, mouse-resizable and responsive to terminal size.\n- Agent todo list rendered in the chat TUI, above the composer, mirroring the web chat's todo card.\n- Fuzzy finder in the conversation and agent pickers.\n- Slash-command autocomplete menu.\n- Paste or drag-and-drop any file into the composer; paste clipboard images with Cmd+V.\n- Run local shell commands with `!` in the composer.\n- Conversation recaps, streamed live title updates, and per-agent attribution on assistant turns.\n- Working timer rolls up into minutes and hours.\n- Conversation picker paginates past 50 conversations and shows only your own conversations.\n- Esc leaves a live run; typing `exit` quits the chat.\n- Cursor Dark theme.\n- Picker rows no longer overlap when a conversation title contains a newline, tab, or control character.\n\n**Headless & scripting**\n\n- Resume a conversation by id.\n- `conversations list` and `conversations show` for transcript reads.\n- `messages get`, with run recovery keyed on message id.\n- Connect cards surface in headless `-p` mode so a driving agent never gets stuck.\n\n**Authentication**\n\n- `skydive auth login` via the browser now auto-mints an API key, so one login yields both a chat session and a usable management credential. Management commands announce the key's pinned workspace when it drives them, so a workspace mismatch is visible at use time.\n- Workspace picker on the device authorization page.\n- Account and workspace identity shown in `auth status`.\n\n**Platform**\n\n- Standalone binary builds compiling the CLI into a per-target executable.\n- Interactive workspace switcher; management commands follow the active workspace.\n- Terminal host integrations and agent notifications.\n\n### Fixed\n\n- Transcript errors collapse to one line, click to expand (REST and portal errors keep their full body).\n- Dragged/pasted image file paths attach the file instead of inserting path text, including macOS paths with literal parentheses.\n- Bare URLs in chat markdown are hyperlinked so they survive text wrap.\n- Composer draft is preserved across TUI overlays.\n- Relative connect links resolve before opening the browser.\n- Numbered markdown headings render colored in the TUI.\n- Chat transcript pages by 75% of a screen; picker rows stay on one line.\n- Run starts push to the TUI over the conversation stream.\n- Security: remediated high-severity dependency findings and cleared tar/shell-quote CVEs.\n\n## [0.1.0] - 2026-07-21\n\nInitial public release: `skydive chat` TUI, agent and conversation management,\ndevice authorization, and headless `-p` mode.\n";
2470
3590
 
2471
3591
  //#endregion
2472
3592
  //#region src/whats-new.ts
@@ -2602,13 +3722,26 @@ function maybePrintWhatsNew({ stderrIsTTY = process.stderr.isTTY ?? false, nonIn
2602
3722
 
2603
3723
  //#endregion
2604
3724
  //#region src/bin.ts
3725
+ installCrashHandler();
2605
3726
  if (process.argv.includes(UPDATE_WORKER_FLAG)) {
2606
3727
  await runUpdateCheckWorker();
2607
3728
  process.exit(0);
2608
3729
  }
2609
- maybePrintWhatsNew();
2610
- setupUpdateCheck(hideBin(process.argv));
2611
- createCli(resolveArgv(hideBin(process.argv))).parse();
3730
+ if (process.argv.includes(PORTAL_DAEMON_FLAG)) {
3731
+ const { runPortalDaemon } = await import("./daemon-Ch_MSDmQ.mjs");
3732
+ runPortalDaemon(process.argv);
3733
+ } else runCli();
3734
+ function runCli() {
3735
+ const completionProbe = isCompletionProbe(process.argv);
3736
+ if (!completionProbe) {
3737
+ maybePrintWhatsNew();
3738
+ setupUpdateCheck(hideBin(process.argv));
3739
+ }
3740
+ const resolved = resolveArgv(normalizeProbeArgv(hideBin(process.argv)));
3741
+ const raw = hideBin(process.argv);
3742
+ if (!completionProbe && (raw.includes("--help") || raw.includes("-h")) && !raw.some((a) => !a.startsWith("-")) && !raw.includes("--json") && !raw.includes("--quiet")) process.stdout.write(brandHelpArt());
3743
+ createCli(resolved).parse();
3744
+ }
2612
3745
 
2613
3746
  //#endregion
2614
3747
  export { };