@shardflux/cli 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +47 -14
- package/dist/acp-journal.js +58 -6
- package/dist/acp-login.d.ts +10 -3
- package/dist/acp-login.js +31 -4
- package/dist/acp-models.d.ts +6 -1
- package/dist/acp-models.js +21 -16
- package/dist/acp.js +60 -7
- package/dist/agent-commands.js +29 -15
- package/dist/agent-target-commands.d.ts +20 -1
- package/dist/agent-target-commands.js +49 -18
- package/dist/agent-targets.js +6 -1
- package/dist/args.d.ts +1 -1
- package/dist/args.js +14 -14
- package/dist/cli-proxy-forward.d.ts +0 -2
- package/dist/cli-proxy-forward.js +2 -9
- package/dist/cli-proxy-hub.d.ts +22 -30
- package/dist/cli-proxy-hub.js +65 -94
- package/dist/cli-proxy.d.ts +12 -38
- package/dist/cli-proxy.js +51 -168
- package/dist/codex-cloud.d.ts +9 -4
- package/dist/codex-cloud.js +47 -14
- package/dist/codex-linux.d.ts +14 -0
- package/dist/codex-linux.js +69 -0
- package/dist/command-center-actions.js +13 -4
- package/dist/command-center-model.d.ts +2 -0
- package/dist/command-center-model.js +6 -1
- package/dist/command-center.js +3 -3
- package/dist/commands.js +2 -2
- package/dist/conversation-commands.js +16 -2
- package/dist/conversation-terminal.js +3 -2
- package/dist/conversations.d.ts +14 -1
- package/dist/conversations.js +61 -7
- package/dist/desktop-account.d.ts +16 -0
- package/dist/desktop-account.js +29 -0
- package/dist/desktop-linux.js +16 -3
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +5 -2
- package/dist/filesystem-commands.js +25 -3
- package/dist/menubar.d.ts +6 -5
- package/dist/menubar.js +40 -28
- package/dist/move-worktrees.d.ts +13 -7
- package/dist/move-worktrees.js +68 -17
- package/dist/move.js +20 -25
- package/dist/onboarding.js +4 -2
- package/dist/prepared-repository.d.ts +2 -0
- package/dist/prepared-repository.js +1 -1
- package/dist/prepared-repository.py +3 -2
- package/dist/project-preparation.d.ts +14 -0
- package/dist/project-preparation.js +16 -4
- package/dist/project-sync.d.ts +3 -0
- package/dist/project-sync.js +4 -1
- package/dist/t3-launch.d.ts +2 -1
- package/dist/t3-launch.js +11 -2
- package/dist/t3-proxy.d.ts +81 -0
- package/dist/t3-proxy.js +325 -0
- package/dist/terminal-apps.d.ts +11 -0
- package/dist/terminal-apps.js +18 -5
- package/dist/update.d.ts +2 -0
- package/dist/update.js +32 -6
- package/package.json +5 -5
package/dist/cli-proxy.js
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* (only one copy may refresh a login), their auth files go to the hub (never printed), the forwarder takes 127.0.0.1:8317
|
|
8
8
|
* and the machine template carries the hub info. Onboarding calls `detectLocalCliProxy()` and `importCliProxy(ctx)`.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The hub is the user's own CLIProxyAPI: it routes requests across their logins as their config says, and each login
|
|
11
|
+
* stays enabled or disabled as they left it. `shard cli-proxy use` is their switch: one login for a provider, or all.
|
|
12
12
|
*/
|
|
13
13
|
import { execFile } from 'node:child_process';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync,
|
|
15
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { connect } from 'node:net';
|
|
17
17
|
import { homedir, userInfo } from 'node:os';
|
|
18
18
|
import { basename, dirname, join } from 'node:path';
|
|
@@ -21,9 +21,9 @@ import { parse as parseYaml } from 'yaml';
|
|
|
21
21
|
import { AgentStateStore } from "./agent-state.js";
|
|
22
22
|
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
23
23
|
import { CliProxyForwarder, IDENTITY_PATH, hubInfoSource } from "./cli-proxy-forward.js";
|
|
24
|
-
import { ADD_AUTH_SCRIPT, CLI_PROXY_PORT, CLI_PROXY_VM_PORT,
|
|
24
|
+
import { ADD_AUTH_SCRIPT, CLI_PROXY_PORT, CLI_PROXY_VM_PORT, HUB_AUTH_DIR, HUB_KEY, HubError, SET_ASIDE_SCRIPT, STOP_SCRIPT, ensureCliProxyHub, hubCallback, listHubLogins, readHubInfo, sdkHubCloud, useHubLogins, vmHubInfoText, writeHubInfo } from "./cli-proxy-hub.js";
|
|
25
25
|
import { CLI_VERSION } from "./args.js";
|
|
26
|
-
import { UsageError
|
|
26
|
+
import { UsageError } from "./errors.js";
|
|
27
27
|
import { json } from "./format.js";
|
|
28
28
|
import { progress } from "./progress.js";
|
|
29
29
|
const execute = promisify(execFile);
|
|
@@ -634,14 +634,6 @@ function entryFile(f) {
|
|
|
634
634
|
return f.path.slice(HUB_AUTH_DIR.length + 1);
|
|
635
635
|
return [f.id, f.name].find((v) => typeof v === 'string' && v !== '') ?? null;
|
|
636
636
|
}
|
|
637
|
-
/**
|
|
638
|
-
* Marks each provider's active login: an enabled login whose file is one of `activeFiles` (the hub's active.json).
|
|
639
|
-
* Without them (unreadable), a provider's only enabled login is its active one.
|
|
640
|
-
*/
|
|
641
|
-
export function markActive(accounts, activeFiles) {
|
|
642
|
-
const enabled = (p) => accounts.filter((a) => a.provider === p && !a.disabled).length;
|
|
643
|
-
return accounts.map((a) => ({ ...a, active: !a.disabled && (activeFiles ? a.file !== null && activeFiles.has(a.file) : enabled(a.provider) === 1) }));
|
|
644
|
-
}
|
|
645
637
|
/** The hub's logins from its management API (wakes the hub). */
|
|
646
638
|
export async function hubAccounts(info, timeoutMs = 45_000) {
|
|
647
639
|
const res = await fetch(new URL('v0/management/auth-files', info.callback_url), {
|
|
@@ -658,7 +650,7 @@ export async function hubAccounts(info, timeoutMs = 45_000) {
|
|
|
658
650
|
status: typeof f.status === 'string' && f.status ? f.status : null,
|
|
659
651
|
disabled: f.disabled === true,
|
|
660
652
|
file: entryFile(f),
|
|
661
|
-
active:
|
|
653
|
+
active: f.disabled !== true,
|
|
662
654
|
}));
|
|
663
655
|
}
|
|
664
656
|
/** Whether the hub answers /healthz through its callback URL, and the edge's reason when it does not. */
|
|
@@ -672,7 +664,7 @@ export async function hubHealth(info, timeoutMs = 45_000) {
|
|
|
672
664
|
return { ok: false, reason: err.name === 'TimeoutError' ? 'timeout' : 'unreachable' };
|
|
673
665
|
}
|
|
674
666
|
}
|
|
675
|
-
const accountLine = (a) => ` ${a.provider.padEnd(8)} ${a.email ?? '(no email)'}${a.
|
|
667
|
+
const accountLine = (a) => ` ${a.provider.padEnd(8)} ${a.email ?? '(no email)'}${a.disabled ? ' disabled' : a.status && a.status !== 'active' ? ` ${a.status}` : ''}`;
|
|
676
668
|
/** The logins a `--use` or `shard cli-proxy use` value names: by email (any case), path in the auth folder, or file name. */
|
|
677
669
|
export function matchLogins(spec, logins) {
|
|
678
670
|
const want = spec.trim().toLowerCase();
|
|
@@ -681,7 +673,8 @@ export function matchLogins(spec, logins) {
|
|
|
681
673
|
const loginName = (l) => l.email ?? l.file;
|
|
682
674
|
const refs = (logins, active) => logins.filter((l) => l.provider !== null && l.active === active).map((l) => ({ provider: l.provider, email: l.email, file: l.file }));
|
|
683
675
|
/**
|
|
684
|
-
* Resolves `--use` (repeatable): each value names one login
|
|
676
|
+
* Resolves `--use` (repeatable): each value names the one login that serves its provider, on this computer or already
|
|
677
|
+
* in the hub.
|
|
685
678
|
* Checked before anything changes, so a typo stops the import with the logins to pick from.
|
|
686
679
|
*/
|
|
687
680
|
function resolveUse(specs, local, hub) {
|
|
@@ -710,23 +703,13 @@ function resolveUse(specs, local, hub) {
|
|
|
710
703
|
const choice = here[0] ? { local: here[0].file, identity: here[0].identity } : { hub: there[0].file };
|
|
711
704
|
const earlier = by.get(provider);
|
|
712
705
|
if (earlier !== undefined && earlier !== spec)
|
|
713
|
-
throw new UsageError(`--use ${earlier} and --use ${spec} both name a ${provider} login; one login per provider
|
|
706
|
+
throw new UsageError(`--use ${earlier} and --use ${spec} both name a ${provider} login; --use names one login per provider.`, 'keep one --use per provider');
|
|
714
707
|
by.set(provider, spec);
|
|
715
708
|
out.set(provider, choice);
|
|
716
709
|
}
|
|
717
710
|
}
|
|
718
711
|
return out;
|
|
719
712
|
}
|
|
720
|
-
/** When a local login was added: its birth time where the file system keeps one, else its last change. */
|
|
721
|
-
function addedAt(path) {
|
|
722
|
-
try {
|
|
723
|
-
const st = statSync(path);
|
|
724
|
-
return st.birthtimeMs > 0 ? st.birthtimeMs : st.mtimeMs;
|
|
725
|
-
}
|
|
726
|
-
catch {
|
|
727
|
-
return Number.MAX_SAFE_INTEGER;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
713
|
const useSpecs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []).filter((x) => x.trim() !== '');
|
|
731
714
|
/**
|
|
732
715
|
* Moves CLIProxyAPI from this computer into the account (or connects this computer to the account's hub): ensure the
|
|
@@ -734,9 +717,9 @@ const useSpecs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []
|
|
|
734
717
|
* one: the hub's copy may hold a newer refresh token), start the forwarder on 127.0.0.1:8317 and put the hub info into
|
|
735
718
|
* the machine template.
|
|
736
719
|
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
720
|
+
* Each login arrives as it is on this computer (enabled or disabled), and the hub routes across the enabled ones as
|
|
721
|
+
* the user's config says. `--use <email|file>` (repeatable, one per provider) makes that login the only one serving
|
|
722
|
+
* its provider (the provider's others are kept in the hub, disabled), as `shard cli-proxy use` does.
|
|
740
723
|
*/
|
|
741
724
|
export async function importCliProxy(ctx, opts = {}) {
|
|
742
725
|
if (cliProxyTools.inVm()) {
|
|
@@ -817,8 +800,7 @@ export async function importCliProxy(ctx, opts = {}) {
|
|
|
817
800
|
machine = { error: err instanceof Error ? err.message : String(err) };
|
|
818
801
|
}
|
|
819
802
|
}
|
|
820
|
-
const
|
|
821
|
-
const accounts = await hubAccounts(ensured.info).then((a) => markActive(a, activeFiles), () => null);
|
|
803
|
+
const accounts = await hubAccounts(ensured.info).catch(() => null);
|
|
822
804
|
return {
|
|
823
805
|
hub: { workspace_id: ensured.info.workspace_id, created: ensured.created, process: ensured.process },
|
|
824
806
|
logins: { uploaded, kept, added, already, moved_aside: movedAside.map((m) => m.to), active: refs(hubLogins, true), disabled: refs(hubLogins, false) },
|
|
@@ -826,57 +808,23 @@ export async function importCliProxy(ctx, opts = {}) {
|
|
|
826
808
|
};
|
|
827
809
|
}
|
|
828
810
|
/**
|
|
829
|
-
* Under the hub's logins lock: uploads this computer's logins the hub lacks
|
|
830
|
-
*
|
|
811
|
+
* Under the hub's logins lock: uploads this computer's logins the hub lacks, each as it is (enabled or disabled), then
|
|
812
|
+
* applies --use. Returns the hub's logins after.
|
|
831
813
|
*/
|
|
832
814
|
async function uploadLogins(ws, local, use, out) {
|
|
833
815
|
const inHub = new Map();
|
|
834
|
-
const providersInHub = new Set();
|
|
835
816
|
const present = await ws.authFiles();
|
|
836
817
|
for (const rel of present) {
|
|
837
818
|
const text = await ws.read(`${HUB_AUTH_DIR}/${rel}`);
|
|
838
819
|
const id = text === null ? `file:${rel}` : loginIdentity(text, rel);
|
|
839
820
|
if (!inHub.has(id))
|
|
840
821
|
inHub.set(id, rel);
|
|
841
|
-
const type = (() => { try {
|
|
842
|
-
const t = JSON.parse(text ?? '').type;
|
|
843
|
-
return typeof t === 'string' && t.trim() ? t.trim().toLowerCase() : null;
|
|
844
|
-
}
|
|
845
|
-
catch {
|
|
846
|
-
return null;
|
|
847
|
-
} })();
|
|
848
|
-
if (type && !/^sfa-[^/]+\.json$/.test(rel))
|
|
849
|
-
providersInHub.add(type);
|
|
850
822
|
}
|
|
851
823
|
const names = new Set(present);
|
|
852
|
-
const
|
|
853
|
-
const
|
|
854
|
-
const
|
|
855
|
-
|
|
856
|
-
try {
|
|
857
|
-
disabled = JSON.parse(text).disabled === true;
|
|
858
|
-
}
|
|
859
|
-
catch { /* not JSON */ }
|
|
860
|
-
return { ...login, text, identity: loginIdentity(text, login.file), at: addedAt(path), disabled };
|
|
861
|
-
});
|
|
862
|
-
// The identity each provider's active login has after this import: --use, else (a provider new to the hub) this
|
|
863
|
-
// computer's oldest enabled login of it, else its oldest. A provider the hub serves keeps its active login.
|
|
864
|
-
const target = new Map();
|
|
865
|
-
for (const [provider, choice] of use)
|
|
866
|
-
if ('local' in choice)
|
|
867
|
-
target.set(provider, choice.identity);
|
|
868
|
-
for (const l of [...logins].sort((a, b) => Number(a.disabled) - Number(b.disabled) || a.at - b.at || (a.file < b.file ? -1 : 1))) {
|
|
869
|
-
const p = l.provider?.toLowerCase();
|
|
870
|
-
if (p && !use.has(p) && !providersInHub.has(p) && !target.has(p))
|
|
871
|
-
target.set(p, l.identity);
|
|
872
|
-
}
|
|
873
|
-
for (const login of logins) {
|
|
874
|
-
const p = login.provider?.toLowerCase() ?? null;
|
|
875
|
-
// Only a provider new to the hub gets its chosen login enabled on upload. Next to a login the hub already serves,
|
|
876
|
-
// every upload arrives disabled (two enabled logins would fail over between each other); enforceActiveLogins below
|
|
877
|
-
// then disables the old one before it enables the chosen one.
|
|
878
|
-
const content = p === null || (target.get(p) === login.identity && !providersInHub.has(p)) ? login.text : (withDisabled(login.text, true) ?? login.text);
|
|
879
|
-
const r = inHub.has(login.identity) || names.has(login.file) ? null : await ws.sh(ADD_AUTH_SCRIPT, [login.file], { stdin: content });
|
|
824
|
+
for (const login of local?.logins ?? []) {
|
|
825
|
+
const text = readFileSync(join(local.authDir, login.file), 'utf8');
|
|
826
|
+
const identity = loginIdentity(text, login.file);
|
|
827
|
+
const r = inHub.has(identity) || names.has(login.file) ? null : await ws.sh(ADD_AUTH_SCRIPT, [login.file], { stdin: text });
|
|
880
828
|
if (r && r.code !== 0)
|
|
881
829
|
throw new HubError(`Could not add ${login.file} to the hub: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, `run: ${AGAIN}`);
|
|
882
830
|
if (!r || r.stdout.trim() === 'exists') {
|
|
@@ -886,19 +834,18 @@ async function uploadLogins(ws, local, use, out) {
|
|
|
886
834
|
}
|
|
887
835
|
out.uploaded.push(login.file);
|
|
888
836
|
out.added.push({ provider: login.provider, email: login.email });
|
|
889
|
-
inHub.set(
|
|
837
|
+
inHub.set(identity, login.file);
|
|
890
838
|
names.add(login.file);
|
|
891
839
|
}
|
|
892
|
-
|
|
840
|
+
if (!use.size)
|
|
841
|
+
return listHubLogins(ws);
|
|
842
|
+
const only = {};
|
|
893
843
|
for (const [provider, choice] of use) {
|
|
894
844
|
const file = 'hub' in choice ? choice.hub : inHub.get(choice.identity);
|
|
895
845
|
if (file !== undefined)
|
|
896
|
-
|
|
846
|
+
only[provider] = file;
|
|
897
847
|
}
|
|
898
|
-
|
|
899
|
-
if (prefer[provider] === undefined && inHub.has(identity))
|
|
900
|
-
prefer[provider] = inHub.get(identity);
|
|
901
|
-
return enforceActiveLogins(ws, prefer);
|
|
848
|
+
return useHubLogins(ws, { only });
|
|
902
849
|
}
|
|
903
850
|
/**
|
|
904
851
|
* Brings the hub's logins back to this computer: the hub stops first (from then on only this computer refreshes
|
|
@@ -978,72 +925,68 @@ async function exportHandler(ctx, values) {
|
|
|
978
925
|
return 0;
|
|
979
926
|
}
|
|
980
927
|
/**
|
|
981
|
-
* `shard cli-proxy use <email|file>`:
|
|
982
|
-
*
|
|
928
|
+
* `shard cli-proxy use <email|file>`: that login alone serves its provider (an email with a Claude and a Codex login
|
|
929
|
+
* sets both); the provider's other logins stay in the hub, disabled. `--all`: every login serves again, and the hub
|
|
930
|
+
* routes across them as its config says. Under the hub's logins lock.
|
|
983
931
|
*/
|
|
984
932
|
export async function useCliProxyLogin(ctx, spec, opts = {}) {
|
|
985
933
|
if (cliProxyTools.inVm())
|
|
986
934
|
throw new UsageError('shard cli-proxy use runs on a computer connected to your hosted CLIProxyAPI; this is a Shardflux VM.', 'run it on that computer: shard cli-proxy use <email>');
|
|
987
|
-
if (!spec.trim())
|
|
988
|
-
throw new UsageError('Name the login to
|
|
935
|
+
if (!opts.all && !spec.trim())
|
|
936
|
+
throw new UsageError('Name the login to use, or --all for every login.', 'run: shard cli-proxy use <email>');
|
|
937
|
+
if (opts.all && spec.trim())
|
|
938
|
+
throw new UsageError('Name a login or use --all, not both.', 'run: shard cli-proxy use --all');
|
|
989
939
|
const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
|
|
990
940
|
const opened = await hub.open(false);
|
|
991
941
|
if (!opened)
|
|
992
942
|
throw new UsageError('This account has no hosted CLIProxyAPI yet.', `move yours: ${AGAIN}`);
|
|
993
943
|
return opened.ws.withLock('logins', async () => {
|
|
994
944
|
const before = await listHubLogins(opened.ws);
|
|
945
|
+
if (opts.all) {
|
|
946
|
+
const after = await useHubLogins(opened.ws, { all: true });
|
|
947
|
+
return { switched: [], active: refs(after, true), disabled: refs(after, false) };
|
|
948
|
+
}
|
|
995
949
|
const matches = matchLogins(spec, before);
|
|
996
950
|
if (!matches.length) {
|
|
997
951
|
const all = before.filter((l) => l.provider).map((l) => `${l.provider} ${loginName(l)}`);
|
|
998
952
|
throw new UsageError(`No login in your hosted CLIProxyAPI matches ${spec}.`, all.length ? `pick one of: ${all.join(', ')}` : `add yours: ${AGAIN}`);
|
|
999
953
|
}
|
|
1000
|
-
const
|
|
954
|
+
const only = {};
|
|
1001
955
|
for (const provider of new Set(matches.map((l) => l.provider))) {
|
|
1002
956
|
const mine = matches.filter((l) => l.provider === provider);
|
|
1003
957
|
if (mine.length > 1)
|
|
1004
958
|
throw new UsageError(`${spec} matches more than one ${provider} login (${mine.map((l) => l.file).join(', ')}).`, 'name one by its file name: shard cli-proxy use <file>');
|
|
1005
|
-
|
|
959
|
+
only[provider] = mine[0].file;
|
|
1006
960
|
}
|
|
1007
|
-
const after = await
|
|
961
|
+
const after = await useHubLogins(opened.ws, { only });
|
|
1008
962
|
const ref = (l) => ({ provider: l.provider, email: l.email, file: l.file });
|
|
1009
|
-
const switched = Object.entries(
|
|
1010
|
-
const was = before.
|
|
1011
|
-
return { provider, from: was ? ref(was) : null, to: ref(after.find((l) => l.file === file)) };
|
|
963
|
+
const switched = Object.entries(only).map(([provider, file]) => {
|
|
964
|
+
const was = before.filter((l) => l.provider === provider && l.active);
|
|
965
|
+
return { provider, from: was.length === 1 ? ref(was[0]) : null, to: ref(after.find((l) => l.file === file)) };
|
|
1012
966
|
});
|
|
1013
967
|
return { switched, active: refs(after, true), disabled: refs(after, false) };
|
|
1014
968
|
});
|
|
1015
969
|
}
|
|
1016
970
|
async function useHandler(ctx, values, positionals) {
|
|
1017
971
|
const spec = positionals[0] ?? (typeof values.login === 'string' ? values.login : '');
|
|
1018
|
-
const r = await useCliProxyLogin(ctx, spec);
|
|
972
|
+
const r = await useCliProxyLogin(ctx, spec, { all: values.all === true });
|
|
1019
973
|
if (ctx.json) {
|
|
1020
974
|
ctx.io.stdout.write(json(r));
|
|
1021
975
|
return 0;
|
|
1022
976
|
}
|
|
1023
|
-
const lines =
|
|
1024
|
-
?
|
|
1025
|
-
:
|
|
977
|
+
const lines = values.all === true
|
|
978
|
+
? [`Every login serves requests, routed as your CLIProxyAPI config says: ${r.active.map((l) => `${l.provider} ${loginName(l)}`).join(', ') || 'none yet'}.`]
|
|
979
|
+
: r.switched.map((s) => (s.from?.file === s.to.file
|
|
980
|
+
? `${loginName(s.to)} already serves your ${s.provider} requests.`
|
|
981
|
+
: `${loginName(s.to)} serves your ${s.provider} requests now. Claude Code, Codex and T3 Code use it from their next request.`));
|
|
1026
982
|
for (const provider of new Set(r.switched.map((s) => s.provider))) {
|
|
1027
983
|
const others = r.disabled.filter((l) => l.provider === provider);
|
|
1028
984
|
if (others.length)
|
|
1029
|
-
lines.push(`Kept in the hub, disabled: ${others.map(loginName).join(', ')}
|
|
985
|
+
lines.push(`Kept in the hub, disabled: ${others.map(loginName).join(', ')}. Use them all again: shard cli-proxy use --all`);
|
|
1030
986
|
}
|
|
1031
987
|
ctx.io.stdout.write(`${lines.join('\n')}\n`);
|
|
1032
988
|
return 0;
|
|
1033
989
|
}
|
|
1034
|
-
/** The hub's active.json as a set of files (null when it cannot be read: status then marks a provider's only enabled login). */
|
|
1035
|
-
async function activeFilesOf(hub) {
|
|
1036
|
-
try {
|
|
1037
|
-
const opened = await hub.open(false);
|
|
1038
|
-
if (!opened)
|
|
1039
|
-
return null;
|
|
1040
|
-
const text = await opened.ws.read(HUB_ACTIVE_FILE);
|
|
1041
|
-
return text === null ? null : new Set(Object.values(parseActive(text)));
|
|
1042
|
-
}
|
|
1043
|
-
catch {
|
|
1044
|
-
return null;
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
990
|
// ---- handlers ------------------------------------------------------------------------------------------------------
|
|
1048
991
|
function statusLines(state, accounts, forwarding, launchd, machineVersion) {
|
|
1049
992
|
const hub = state === null ? 'missing (run shard cli-proxy import)' : state === 'running' ? 'running' : ['suspended', 'parked', 'stopped'].includes(state) ? 'sleeping (wakes on the next request)' : state;
|
|
@@ -1125,8 +1068,6 @@ async function status(ctx, values) {
|
|
|
1125
1068
|
}
|
|
1126
1069
|
if (health.ok)
|
|
1127
1070
|
accounts = await hubAccounts(info).catch(() => null);
|
|
1128
|
-
if (accounts)
|
|
1129
|
-
accounts = markActive(accounts, await activeFilesOf(hub));
|
|
1130
1071
|
}
|
|
1131
1072
|
const identity = await cliProxyTools.identity();
|
|
1132
1073
|
const launchd = existsSync(forwarderPlistPath(ctx.io.env.HOME || homedir()));
|
|
@@ -1154,8 +1095,7 @@ async function importHandler(ctx, values) {
|
|
|
1154
1095
|
...(r.config === 'kept' ? ['The hub keeps its settings (client keys, routing, upstream keys).'] : []),
|
|
1155
1096
|
...r.logins.moved_aside.map((dir) => `This computer’s auth files are in ${dir} now, where no CLIProxyAPI reads them.`),
|
|
1156
1097
|
...(r.port !== CLI_PROXY_PORT ? [`Point tools that used http://127.0.0.1:${r.port} at http://127.0.0.1:${CLI_PROXY_PORT}.`] : []),
|
|
1157
|
-
...(r.logins.
|
|
1158
|
-
...(r.logins.disabled.length ? [`Kept in the hub, disabled: ${r.logins.disabled.map((l) => `${l.provider} ${loginName(l)}`).join(', ')}. Switch any time: shard cli-proxy use <email>`] : []),
|
|
1098
|
+
...(r.logins.disabled.length ? [`Kept in the hub, disabled: ${r.logins.disabled.map((l) => `${l.provider} ${loginName(l)}`).join(', ')}. Use them too: shard cli-proxy use --all`] : []),
|
|
1159
1099
|
...(r.accounts ? [`Accounts: ${r.accounts.length}`, ...r.accounts.map(accountLine)] : []),
|
|
1160
1100
|
r.forwarder.launchd ? `This computer: http://127.0.0.1:${CLI_PROXY_PORT} forwards to it (starts at login).` : `This computer: run shard cli-proxy forward to reach it at http://127.0.0.1:${CLI_PROXY_PORT}.`,
|
|
1161
1101
|
r.machine && 'version' in r.machine ? `VMs: every new VM reaches it at http://127.0.0.1:${CLI_PROXY_PORT} (machine version ${r.machine.version}).` : '',
|
|
@@ -1169,12 +1109,9 @@ async function importHandler(ctx, values) {
|
|
|
1169
1109
|
async function forwardHandler(ctx) {
|
|
1170
1110
|
const source = hubInfoSource(ctx.store.dir);
|
|
1171
1111
|
const log = (line) => void ctx.io.stderr.write(`${new Date().toISOString()} ${line}\n`);
|
|
1172
|
-
let checks = null;
|
|
1173
1112
|
const forwarder = new CliProxyForwarder({
|
|
1174
1113
|
hub: source,
|
|
1175
1114
|
log,
|
|
1176
|
-
// The first request after the hub slept: check its logins as it wakes.
|
|
1177
|
-
onRequest: () => checks?.nudge(),
|
|
1178
1115
|
repair: async () => {
|
|
1179
1116
|
const info = source();
|
|
1180
1117
|
if (!info || info.api_url !== ctx.apiUrl)
|
|
@@ -1192,68 +1129,14 @@ async function forwardHandler(ctx) {
|
|
|
1192
1129
|
if (!source())
|
|
1193
1130
|
log('not connected to a hosted CLIProxyAPI yet; answering 503 until shard cli-proxy import connects this computer');
|
|
1194
1131
|
await forwarder.listen();
|
|
1195
|
-
checks = hubLoginChecks(ctx, source, log);
|
|
1196
1132
|
await new Promise((resolve) => {
|
|
1197
1133
|
if (ctx.signal.aborted)
|
|
1198
1134
|
return resolve();
|
|
1199
1135
|
ctx.signal.addEventListener('abort', () => resolve(), { once: true });
|
|
1200
1136
|
});
|
|
1201
|
-
checks.stop();
|
|
1202
1137
|
await forwarder.close();
|
|
1203
1138
|
return 0;
|
|
1204
1139
|
}
|
|
1205
|
-
/** How often the forwarder checks that the hub serves one login per provider while it runs. */
|
|
1206
|
-
export const HUB_LOGIN_CHECK_MS = 15 * 60_000;
|
|
1207
|
-
/** How soon it looks again when the hub was asleep (a VM's request may wake it any moment; looking never wakes it). */
|
|
1208
|
-
export const HUB_LOGIN_ASLEEP_MS = 60_000;
|
|
1209
|
-
/** The least time between two checks a request asks for (requests come in bursts). */
|
|
1210
|
-
export const HUB_LOGIN_NUDGE_MS = 10_000;
|
|
1211
|
-
/**
|
|
1212
|
-
* The forwarder's check that the hub serves one login per provider (enforceHubLoginsIfRunning), never waking the hub:
|
|
1213
|
-
* at start; then every HUB_LOGIN_CHECK_MS while it runs and every HUB_LOGIN_ASLEEP_MS while it sleeps; and at the first
|
|
1214
|
-
* request after it slept (nudge), so a hub set up before one login per provider cannot fail over between logins for
|
|
1215
|
-
* long after it wakes. One check at a time.
|
|
1216
|
-
*/
|
|
1217
|
-
export function hubLoginChecks(ctx, source, log, opts = {}) {
|
|
1218
|
-
const everyMs = opts.everyMs ?? HUB_LOGIN_CHECK_MS, asleepMs = opts.asleepMs ?? HUB_LOGIN_ASLEEP_MS, nudgeMs = opts.nudgeMs ?? HUB_LOGIN_NUDGE_MS;
|
|
1219
|
-
let running = false, stopped = false, last = null, lastAt = 0;
|
|
1220
|
-
let timer;
|
|
1221
|
-
const schedule = (ms) => { clearTimeout(timer); if (!stopped) {
|
|
1222
|
-
timer = setTimeout(() => void check(), ms);
|
|
1223
|
-
timer.unref?.();
|
|
1224
|
-
} };
|
|
1225
|
-
const check = async () => {
|
|
1226
|
-
const info = source();
|
|
1227
|
-
if (running || stopped)
|
|
1228
|
-
return;
|
|
1229
|
-
if (!info || info.api_url !== ctx.apiUrl) {
|
|
1230
|
-
schedule(everyMs);
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
running = true;
|
|
1234
|
-
try {
|
|
1235
|
-
const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
|
|
1236
|
-
last = await enforceHubLoginsIfRunning(hub, info.workspace_id);
|
|
1237
|
-
if (last === 'enforced')
|
|
1238
|
-
log('the hub serves one login per provider');
|
|
1239
|
-
}
|
|
1240
|
-
catch (err) {
|
|
1241
|
-
last = 'failed';
|
|
1242
|
-
log(`could not check the hub's logins (${describeError(err).message}); checking again soon`);
|
|
1243
|
-
}
|
|
1244
|
-
finally {
|
|
1245
|
-
running = false;
|
|
1246
|
-
lastAt = Date.now();
|
|
1247
|
-
schedule(last === 'enforced' || last === 'missing' ? everyMs : asleepMs);
|
|
1248
|
-
}
|
|
1249
|
-
};
|
|
1250
|
-
void check();
|
|
1251
|
-
return {
|
|
1252
|
-
nudge() { if (!running && !stopped && (last !== 'enforced' || Date.now() - lastAt >= everyMs) && Date.now() - lastAt >= nudgeMs)
|
|
1253
|
-
void check(); },
|
|
1254
|
-
stop() { stopped = true; clearTimeout(timer); },
|
|
1255
|
-
};
|
|
1256
|
-
}
|
|
1257
1140
|
/**
|
|
1258
1141
|
* After the edge refused the hub's callback URL (`callback_invalid`: the API key that created it was revoked):
|
|
1259
1142
|
* replaces the URLs the edge refuses (under the hub's lock), saves them locally and gives VMs the new VM address.
|
package/dist/codex-cloud.d.ts
CHANGED
|
@@ -5,11 +5,16 @@ import type { ModelChoice } from './agent-models.js';
|
|
|
5
5
|
import type { Conversation } from './conversations.js';
|
|
6
6
|
import type { LocalConversation } from './conversations.js';
|
|
7
7
|
import type { InitialImage } from './initial-prompt.js';
|
|
8
|
-
/**
|
|
9
|
-
export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env']): Promise<void>;
|
|
8
|
+
/** Cloud threads open in the browser, preserving the durable provider destination on every OS. */
|
|
9
|
+
export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env'], platform?: NodeJS.Platform): Promise<void>;
|
|
10
10
|
export declare function submitCloudInitialPrompt(ctx: Ctx, c: Conversation, prompt: string, workspaceKey: string): Promise<void>;
|
|
11
|
-
/** Prints the Cloud thread (and its link) and opens it in
|
|
11
|
+
/** Prints the Cloud thread (and its link) and opens it in your browser; --json prints it instead. */
|
|
12
12
|
export declare function printCloudThread(ctx: Ctx, c: Conversation, task: CodexCloudTask, submitted?: boolean, choice?: ModelChoice): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* A Cloud conversation where the Codex app cannot show it (0.23.0+): where its chat lives, whether it waits for an
|
|
15
|
+
* answer, and how to answer and follow it from here. Never a new start: the conversation exists.
|
|
16
|
+
*/
|
|
17
|
+
export declare function cloudChatElsewhere(c: Pick<Conversation, 'id' | 'key' | 'state'>, url: string): string;
|
|
13
18
|
/** How long the account's Codex remote device may take to come up before it counts as stuck. */
|
|
14
19
|
export declare const CODEX_STARTING_LIMIT_MS: number;
|
|
15
20
|
export type CodexConnectorView = {
|
|
@@ -51,7 +56,7 @@ export declare function ensureCodexCloud(ctx: Ctx): Promise<{
|
|
|
51
56
|
export declare function launchCodexCloud(ctx: Ctx, values: Values, path: string, images?: readonly InitialImage[]): Promise<number>;
|
|
52
57
|
/** A start whose carrying process said so in the last 45 s. */
|
|
53
58
|
export declare const startCarried: (start: NonNullable<LocalConversation["cloud_start"]>) => boolean;
|
|
54
|
-
export declare function openCodexConversation(ctx: Ctx, c: Conversation): Promise<number>;
|
|
59
|
+
export declare function openCodexConversation(ctx: Ctx, c: Conversation, history?: true | string): Promise<number>;
|
|
55
60
|
/** Continues the same accepted identity, never creates a replacement VM/chat. */
|
|
56
61
|
export declare function resumeCloudStartup(ctx: Ctx, c: Conversation): Promise<CodexCloudTask>;
|
|
57
62
|
export declare const CODEX_CLOUD_HANDLERS: Record<string, Handler>;
|
package/dist/codex-cloud.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
2
3
|
import { CodedError, UsageError } from "./errors.js";
|
|
3
4
|
import { json } from "./format.js";
|
|
@@ -5,25 +6,29 @@ import { syncAgentCredentials } from "./agent-credentials.js";
|
|
|
5
6
|
import { createConversation, conversationWorkspace, getConversation, localConversations, recordedChoice, resumeConversation, seedConversation } from "./conversations.js";
|
|
6
7
|
import { CHOICE_PENDING, choiceReport, hasChoice, modelChoice } from "./agent-models.js";
|
|
7
8
|
import { AgentStateStore } from "./agent-state.js";
|
|
9
|
+
import { installedTargets } from "./agent-targets.js";
|
|
8
10
|
import { ensureProjectGit } from "./project-git.js";
|
|
9
11
|
import { gitRunner } from "./agent-git.js";
|
|
10
12
|
import { projectIdentity } from "./project-identity.js";
|
|
11
13
|
import { uploadInitialImages } from "./initial-prompt.js";
|
|
12
|
-
/**
|
|
13
|
-
export async function openCodexThread(task, env) {
|
|
14
|
+
/** Cloud threads open in the browser, preserving the durable provider destination on every OS. */
|
|
15
|
+
export async function openCodexThread(task, env, platform = process.platform) {
|
|
14
16
|
const url = threadLink(task);
|
|
15
|
-
const program =
|
|
16
|
-
const args =
|
|
17
|
+
const program = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
18
|
+
const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
17
19
|
const child = spawn(program, args, { env, stdio: 'ignore' });
|
|
18
|
-
await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('
|
|
20
|
+
await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Cloud chat did not open in time')); }, 10000); child.once('error', error => { clearTimeout(timer); reject(error); }); child.once('exit', code => { clearTimeout(timer); if (code === 0)
|
|
19
21
|
resolve();
|
|
20
22
|
else
|
|
21
|
-
reject(new Error(`Open this Cloud thread
|
|
23
|
+
reject(new Error(`Open this Cloud thread: ${url}`)); }); });
|
|
22
24
|
}
|
|
23
25
|
function threadLink(task) {
|
|
24
|
-
if (!task.thread_id || !/^[a-zA-Z0-9_-]{1,128}$/.test(task.thread_id)
|
|
26
|
+
if (!task.thread_id || !/^[a-zA-Z0-9_-]{1,128}$/.test(task.thread_id))
|
|
25
27
|
throw new Error('Cloud returned an invalid Codex thread link');
|
|
26
|
-
|
|
28
|
+
const url = `https://chatgpt.com/local/${encodeURIComponent(task.thread_id)}`;
|
|
29
|
+
if (task.web_url != null && task.web_url !== url || task.desktop_url != null && task.desktop_url !== `codex://threads/${encodeURIComponent(task.thread_id)}?hostId=durable`)
|
|
30
|
+
throw new Error('Cloud returned an invalid Codex thread link');
|
|
31
|
+
return url;
|
|
27
32
|
}
|
|
28
33
|
export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
|
|
29
34
|
try {
|
|
@@ -33,19 +38,32 @@ export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
|
|
|
33
38
|
throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt could not be confirmed. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, conversation_id: c.id, submitted: null });
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
|
-
/** Prints the Cloud thread (and its link) and opens it in
|
|
41
|
+
/** Prints the Cloud thread (and its link) and opens it in your browser; --json prints it instead. */
|
|
37
42
|
export async function printCloudThread(ctx, c, task, submitted = false, choice) { return output(ctx, c, task, submitted, choice); }
|
|
43
|
+
/** Whether this computer has the Codex app, which opens a Cloud thread's codex:// link (on Linux 0.23.0+). */
|
|
44
|
+
const codexAppHere = (env) => installedTargets(env ?? {}).clients.includes('codex-app');
|
|
45
|
+
/**
|
|
46
|
+
* A Cloud conversation where the Codex app cannot show it (0.23.0+): where its chat lives, whether it waits for an
|
|
47
|
+
* answer, and how to answer and follow it from here. Never a new start: the conversation exists.
|
|
48
|
+
*/
|
|
49
|
+
export function cloudChatElsewhere(c, url) {
|
|
50
|
+
return `${c.key} runs in Codex Cloud${c.state === 'needs_you' ? ' and is waiting for your answer' : ''}. Its chat opens in your browser: ${url}\n` +
|
|
51
|
+
`Answer it from here: shard conversations send ${c.id} "<your answer>"\nWhere it stands: shard conversations get ${c.id}\n`;
|
|
52
|
+
}
|
|
38
53
|
async function output(ctx, c, task, submitted = false, choice) {
|
|
39
|
-
const url = threadLink(task);
|
|
54
|
+
const url = threadLink(task), app = codexAppHere(ctx.io.env);
|
|
40
55
|
// model_choice (0.17.0+): an admitted thread's record keeps the model and effort only when Cloud started it with them.
|
|
41
56
|
const applied = hasChoice(choice) ? await cloudChoiceApplied(ctx, c, choice) : true;
|
|
57
|
+
// codex_app and reply_command (0.23.0+): without the Codex app, the conversation is answered with that command.
|
|
42
58
|
if (ctx.json) {
|
|
43
|
-
ctx.io.stdout.write(json({ conversation: c, cloud: task, url, ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
|
|
59
|
+
ctx.io.stdout.write(json({ conversation: c, cloud: task, url, previous_threads: (task.previous_thread_ids ?? []).map(id => ({ thread_id: id, url: threadLink({ thread_id: id }) })), codex_app: app, reply_command: ['shard', 'conversations', 'send', c.id, '<message>'], ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
|
|
44
60
|
return;
|
|
45
61
|
}
|
|
46
62
|
if (!applied)
|
|
47
63
|
ctx.io.stderr.write(`${CHOICE_PENDING}\n`);
|
|
48
|
-
ctx.io.stdout.write(`${c.key}\n${
|
|
64
|
+
ctx.io.stdout.write(`${c.key}\n${url}\n`);
|
|
65
|
+
for (const id of task.previous_thread_ids ?? [])
|
|
66
|
+
ctx.io.stdout.write(`Earlier history: https://chatgpt.com/local/${encodeURIComponent(id)}\n`);
|
|
49
67
|
await openCodexThread(task, ctx.io.env);
|
|
50
68
|
}
|
|
51
69
|
async function cloudChoiceApplied(ctx, c, choice) {
|
|
@@ -183,7 +201,14 @@ function carryStart(ctx, id) {
|
|
|
183
201
|
}
|
|
184
202
|
/** A start whose carrying process said so in the last 45 s. */
|
|
185
203
|
export const startCarried = (start) => Date.now() - (start.heartbeat_at ?? 0) < 45_000;
|
|
186
|
-
export async function openCodexConversation(ctx, c) {
|
|
204
|
+
export async function openCodexConversation(ctx, c, history) {
|
|
205
|
+
if (history) {
|
|
206
|
+
const task = await ctx.cloud().codex.task(c.id), ids = task.previous_thread_ids ?? [];
|
|
207
|
+
const id = history === true ? ids[0] : history;
|
|
208
|
+
if (!id || !ids.includes(id))
|
|
209
|
+
throw new UsageError('This conversation has no matching earlier Cloud history.');
|
|
210
|
+
return output(ctx, c, { ...task, thread_id: id, desktop_url: null, web_url: null }, false).then(() => 0);
|
|
211
|
+
}
|
|
187
212
|
const local = localConversations(ctx)[c.id];
|
|
188
213
|
if (local?.cloud_start && local.cloud_start.state !== 'active' && !c.ended_at) {
|
|
189
214
|
const task = await resumeCloudStartup(ctx, c);
|
|
@@ -197,7 +222,15 @@ export async function openCodexConversation(ctx, c) {
|
|
|
197
222
|
await output(ctx, c, task);
|
|
198
223
|
return 0;
|
|
199
224
|
}
|
|
200
|
-
|
|
225
|
+
let task = await ctx.cloud().codex.task(c.id);
|
|
226
|
+
// A thread a connector change stopped (Codex Cloud turned off and on, or the active Codex account switched) continues
|
|
227
|
+
// in a new Cloud thread of this same conversation, on its VM and branch: an API that admits it again does so on
|
|
228
|
+
// request (0.23.0+); an older one answers with the stopped thread.
|
|
229
|
+
if (task.state === 'canceled') {
|
|
230
|
+
task = await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-readmit-${c.id}-${randomUUID()}`, signal: ctx.signal });
|
|
231
|
+
if (task.state !== 'canceled')
|
|
232
|
+
task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
233
|
+
}
|
|
201
234
|
if (task.state === 'canceled')
|
|
202
235
|
throw new UsageError('This conversation’s Codex executor was removed. Start a new Cloud conversation; its saved branch and history remain available.');
|
|
203
236
|
if (task.state !== 'active' || !task.thread_id)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type Env = Record<string, string | undefined>;
|
|
2
|
+
/** A Codex app install: its desktop entry and that entry's Exec as argv (field codes removed). */
|
|
3
|
+
export interface LinuxCodexApp {
|
|
4
|
+
desktop_id: string;
|
|
5
|
+
exec: string[];
|
|
6
|
+
}
|
|
7
|
+
/** The installed Codex apps: entries that handle codex:// and whose program exists. The first entry with an id wins (XDG precedence). */
|
|
8
|
+
export declare function linuxCodexApps(dirs: string[], env: Env): LinuxCodexApp[];
|
|
9
|
+
/**
|
|
10
|
+
* Opens a codex:// link in that install, as the desktop does: its Exec with the link. Detached, so a cold start keeps
|
|
11
|
+
* running after `shard` exits; a running app takes the link in its window.
|
|
12
|
+
*/
|
|
13
|
+
export declare function openLinuxCodexLink(app: LinuxCodexApp, url: string, env: Env): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Codex app on Linux (0.23.0+): OpenAI's ChatGPT app for Linux (package `chatgpt`: `chatgpt.desktop`,
|
|
3
|
+
* `Exec=chatgpt %U`) and community builds (`codex-desktop.desktop`) register the codex:// scheme, which opens a Codex
|
|
4
|
+
* thread (`codex://threads/<id>?hostId=durable` for a Cloud conversation). The app is the desktop entry in the XDG
|
|
5
|
+
* application folders (linuxApplicationDirs) that handles x-scheme-handler/codex.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
10
|
+
import { desktopEntry, desktopExec, t3OpenEnv } from "./t3-linux.js";
|
|
11
|
+
/** Entry names worth reading (every entry in the folders would be hundreds of files on each `shard` run). */
|
|
12
|
+
const MAYBE_ENTRY = /chatgpt|codex|openai/i;
|
|
13
|
+
const executable = (path) => { try {
|
|
14
|
+
accessSync(path, constants.X_OK);
|
|
15
|
+
return statSync(path).isFile();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
} };
|
|
20
|
+
const onPath = (name, env) => isAbsolute(name) ? executable(name) : (env.PATH ?? '').split(delimiter).filter(Boolean).some(dir => executable(join(dir, name)));
|
|
21
|
+
/** The installed Codex apps: entries that handle codex:// and whose program exists. The first entry with an id wins (XDG precedence). */
|
|
22
|
+
export function linuxCodexApps(dirs, env) {
|
|
23
|
+
const found = [], ids = new Set();
|
|
24
|
+
for (const dir of dirs) {
|
|
25
|
+
let names;
|
|
26
|
+
try {
|
|
27
|
+
names = readdirSync(dir);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
if (!name.endsWith('.desktop') || !MAYBE_ENTRY.test(name) || ids.has(name))
|
|
34
|
+
continue;
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = readFileSync(join(dir, name), 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
ids.add(name);
|
|
43
|
+
const e = desktopEntry(text);
|
|
44
|
+
if (e.Hidden === 'true' || (e.Type ?? 'Application') !== 'Application' || !(e.MimeType ?? '').split(';').includes('x-scheme-handler/codex'))
|
|
45
|
+
continue;
|
|
46
|
+
if (e.TryExec && !onPath(e.TryExec, env))
|
|
47
|
+
continue;
|
|
48
|
+
const exec = desktopExec(e.Exec ?? '');
|
|
49
|
+
// `env VAR=value /usr/bin/codex-desktop %u` (a community entry): the program after the assignments must exist.
|
|
50
|
+
const program = exec?.[0] === 'env' ? exec.slice(1).find(a => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) : exec?.[0];
|
|
51
|
+
if (!exec?.length || !program || !onPath(program, env))
|
|
52
|
+
continue;
|
|
53
|
+
found.push({ desktop_id: name, exec });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Opens a codex:// link in that install, as the desktop does: its Exec with the link. Detached, so a cold start keeps
|
|
60
|
+
* running after `shard` exits; a running app takes the link in its window.
|
|
61
|
+
*/
|
|
62
|
+
export async function openLinuxCodexLink(app, url, env) {
|
|
63
|
+
const [command, ...args] = app.exec;
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
const child = spawn(command, [...args, url], { env: t3OpenEnv(env), stdio: 'ignore', detached: true });
|
|
66
|
+
child.once('spawn', () => { child.unref(); resolve(); });
|
|
67
|
+
child.once('error', reject);
|
|
68
|
+
});
|
|
69
|
+
}
|