@shardflux/cli 0.11.1 → 0.13.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 +64 -1
- package/README.md +83 -15
- package/dist/account-args.js +2 -0
- package/dist/account-commands.js +39 -0
- package/dist/agent-commands.d.ts +8 -0
- package/dist/agent-commands.js +223 -0
- package/dist/agent-config-files.d.ts +23 -0
- package/dist/agent-config-files.js +203 -0
- package/dist/agent-config-mcp.d.ts +36 -0
- package/dist/agent-config-mcp.js +225 -0
- package/dist/agent-config-sync.d.ts +58 -0
- package/dist/agent-config-sync.js +366 -0
- package/dist/agent-credentials.d.ts +14 -0
- package/dist/agent-credentials.js +93 -0
- package/dist/agent-git-commands.d.ts +10 -0
- package/dist/agent-git-commands.js +294 -0
- package/dist/agent-git.d.ts +47 -0
- package/dist/agent-git.js +285 -0
- package/dist/agent-reachability.d.ts +4 -0
- package/dist/agent-reachability.js +13 -0
- package/dist/agent-repository.d.ts +13 -0
- package/dist/agent-repository.js +182 -0
- package/dist/agent-state.d.ts +16 -0
- package/dist/agent-state.js +63 -0
- package/dist/agent-target-commands.d.ts +10 -0
- package/dist/agent-target-commands.js +171 -0
- package/dist/agent-targets.d.ts +33 -0
- package/dist/agent-targets.js +267 -0
- package/dist/agent-terminal.d.ts +35 -0
- package/dist/agent-terminal.js +209 -0
- package/dist/args.d.ts +8 -4
- package/dist/args.js +120 -10
- package/dist/assisted-setup.d.ts +24 -0
- package/dist/assisted-setup.js +77 -0
- package/dist/browser-commands.d.ts +3 -0
- package/dist/browser-commands.js +83 -0
- package/dist/browser-profile-copy.d.ts +2 -0
- package/dist/browser-profile-copy.js +2 -0
- package/dist/browser-readers.d.ts +13 -0
- package/dist/browser-readers.js +249 -0
- package/dist/browser-sites.d.ts +1 -0
- package/dist/browser-sites.js +13 -0
- package/dist/claude-cloud-login.d.ts +9 -0
- package/dist/claude-cloud-login.js +143 -0
- package/dist/command-center-actions.d.ts +6 -0
- package/dist/command-center-actions.js +132 -0
- package/dist/command-center-keys.d.ts +6 -0
- package/dist/command-center-keys.js +38 -0
- package/dist/command-center-model.d.ts +29 -0
- package/dist/command-center-model.js +90 -0
- package/dist/command-center-renderer.d.ts +25 -0
- package/dist/command-center-renderer.js +100 -0
- package/dist/command-center.d.ts +18 -0
- package/dist/command-center.js +320 -0
- package/dist/commands.d.ts +8 -2
- package/dist/commands.js +123 -8
- package/dist/credential-prompts.d.ts +22 -0
- package/dist/credential-prompts.js +193 -0
- package/dist/filesystem-commands.d.ts +2 -0
- package/dist/filesystem-commands.js +63 -0
- package/dist/filesystem-state.d.ts +3 -0
- package/dist/filesystem-state.js +12 -0
- package/dist/git-remote-bin.d.ts +2 -0
- package/dist/git-remote-bin.js +37 -0
- package/dist/git-remote-helper.d.ts +8 -0
- package/dist/git-remote-helper.js +31 -0
- package/dist/git-remote.d.ts +14 -0
- package/dist/git-remote.js +136 -0
- package/dist/handlers.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/input.d.ts +5 -0
- package/dist/main.js +21 -5
- package/dist/menubar.d.ts +32 -0
- package/dist/menubar.js +180 -0
- package/dist/project-identity.d.ts +14 -0
- package/dist/project-identity.js +61 -0
- package/dist/project-sync-manifest.d.ts +27 -0
- package/dist/project-sync-manifest.js +225 -0
- package/dist/project-sync-transfer.d.ts +14 -0
- package/dist/project-sync-transfer.js +240 -0
- package/dist/project-sync.d.ts +38 -0
- package/dist/project-sync.js +243 -0
- package/dist/project-template-setup.d.ts +17 -0
- package/dist/project-template-setup.js +89 -0
- package/dist/vault.d.ts +16 -0
- package/dist/vault.js +153 -0
- package/package.json +9 -2
package/dist/commands.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { agentReachability } from "./agent-reachability.js";
|
|
2
|
+
import { vaultType, vaultValue, SECRET_SECURITY_NOTE } from "./vault.js";
|
|
1
3
|
/**
|
|
2
4
|
* Command implementations of the project-key commands (workspaces, files, operations, secrets, templates, usage).
|
|
3
5
|
* Everything goes through @shardflux/sdk (the same client, retries, idempotency keys, operation polling and
|
|
@@ -6,6 +8,7 @@
|
|
|
6
8
|
* joins both tables.
|
|
7
9
|
*/
|
|
8
10
|
import { computerStreamCache } from "./computer-stream-cache.js";
|
|
11
|
+
import { agentConfigLikeSecrets, syncAgentConfig } from "./agent-config-sync.js";
|
|
9
12
|
import { randomUUID } from 'node:crypto';
|
|
10
13
|
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
11
14
|
import { basename, dirname, resolve as resolvePath } from 'node:path';
|
|
@@ -17,7 +20,7 @@ import { updateCheckDisabled } from "./update-check.js";
|
|
|
17
20
|
import { initTemplateYaml, recipeYaml } from "./template-yaml.js";
|
|
18
21
|
import { BuildTimer, buildTimingJson, buildTimingText, progressLine } from "./timing.js";
|
|
19
22
|
import { timingJson } from "./timing.js";
|
|
20
|
-
import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, executionJson, executionSummary, fileInfoText, fileTable, languageTable, json, kv, memoryGrowText, burstText, modeText, operationDetail, pairs, resizeJson, resizeText, operationTable, packageTable, searchText, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, durableText, exposedPortText, portTable, suspendRequestText, suspendWhenIdleText, templateDetailText, templateEntryText, templateFileTable, templateTable, time, usageEstimateText, usageSeriesText, usageText, workspaceDetail, workspaceTable, } from "./format.js";
|
|
23
|
+
import { table, bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, executionJson, executionSummary, fileInfoText, fileTable, languageTable, json, kv, memoryGrowText, burstText, modeText, operationDetail, pairs, resizeJson, resizeText, operationTable, packageTable, searchText, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, durableText, exposedPortText, portTable, suspendRequestText, suspendWhenIdleText, templateDetailText, templateEntryText, templateFileTable, templateTable, time, usageEstimateText, usageSeriesText, usageText, workspaceDetail, workspaceTable, } from "./format.js";
|
|
21
24
|
const DEFAULT_WAIT_MS = 300_000;
|
|
22
25
|
const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
|
|
23
26
|
const MAX_STDIN = 1024 * 1024;
|
|
@@ -54,6 +57,23 @@ function withTiming(ctx, machine) {
|
|
|
54
57
|
export function out(ctx, human, machine) {
|
|
55
58
|
ctx.io.stdout.write(ctx.json ? json(withTiming(ctx, machine)) : human);
|
|
56
59
|
}
|
|
60
|
+
export function openVolumes(values) {
|
|
61
|
+
if (values.length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
const mounts = {};
|
|
64
|
+
for (const value of values) {
|
|
65
|
+
const match = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?):(\/[^:]+)(?::(ro|rw))?$/.exec(value);
|
|
66
|
+
if (!match)
|
|
67
|
+
throw new UsageError('--volume expects name:/absolute/path[:ro]');
|
|
68
|
+
const [, name, path, mode] = match;
|
|
69
|
+
if (Object.hasOwn(mounts, path))
|
|
70
|
+
throw new UsageError(`--volume names mount ${path} twice`);
|
|
71
|
+
if (Object.values(mounts).some((v) => (typeof v === 'string' ? v : v.name) === name))
|
|
72
|
+
throw new UsageError(`--volume names volume ${name} twice`);
|
|
73
|
+
mounts[path] = mode ? { name: name, mode: mode } : name;
|
|
74
|
+
}
|
|
75
|
+
return mounts;
|
|
76
|
+
}
|
|
57
77
|
function caps(values) {
|
|
58
78
|
const c = {};
|
|
59
79
|
const cpu = str(values['cpu-millis']);
|
|
@@ -105,7 +125,7 @@ function resizeParams(values) {
|
|
|
105
125
|
* The caps of `workspaces open` and `workspaces fork` (0.8.0): the resource caps plus the memory allocation. Given caps
|
|
106
126
|
* replace the stored ones, so they are sent only when a flag was given.
|
|
107
127
|
*/
|
|
108
|
-
function workspaceCaps(values) {
|
|
128
|
+
export function workspaceCaps(values) {
|
|
109
129
|
const c = caps(values) ?? {};
|
|
110
130
|
const mode = oneOf(values, 'allocation-mode', ALLOCATION_MODES);
|
|
111
131
|
const held = str(values['memory-held']);
|
|
@@ -651,9 +671,23 @@ function imageOpts(values) {
|
|
|
651
671
|
function lookOpts(values) {
|
|
652
672
|
const shotTo = str(values.screenshot);
|
|
653
673
|
const settle = str(values.settle);
|
|
674
|
+
const pixelText = str(values['pixel-predicate']);
|
|
675
|
+
let pixelPredicate;
|
|
676
|
+
if (pixelText !== undefined) {
|
|
677
|
+
try {
|
|
678
|
+
pixelPredicate = JSON.parse(pixelText);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
throw new UsageError('--pixel-predicate must be a JSON object');
|
|
682
|
+
}
|
|
683
|
+
if (!pixelPredicate || typeof pixelPredicate !== 'object' || Array.isArray(pixelPredicate))
|
|
684
|
+
throw new UsageError('--pixel-predicate must be a JSON object');
|
|
685
|
+
if (shotTo === undefined || settle !== undefined || str(values.format) === 'jpeg')
|
|
686
|
+
throw new UsageError('--pixel-predicate requires --screenshot, PNG and no --settle');
|
|
687
|
+
}
|
|
654
688
|
if (settle !== undefined && (!/^\d+$/.test(settle.trim()) || Number(settle) > 10_000))
|
|
655
689
|
throw new UsageError(`--settle must be 0 to 10000 milliseconds (got "${settle}")`);
|
|
656
|
-
return { shotTo, req: { screenshot: shotTo !== undefined, ...(settle !== undefined ? { settle_ms: Number(settle) } : {}), ...imageOpts(values) } };
|
|
690
|
+
return { shotTo, req: { screenshot: shotTo !== undefined, ...(pixelPredicate ? { pixel_predicate: pixelPredicate } : {}), ...(settle !== undefined ? { settle_ms: Number(settle) } : {}), ...imageOpts(values) } };
|
|
657
691
|
}
|
|
658
692
|
/** A screen coordinate argument (pixels from the top-left). */
|
|
659
693
|
function pixel(text, name) {
|
|
@@ -687,7 +721,9 @@ async function runComputer(ctx, ref, actions, look) {
|
|
|
687
721
|
});
|
|
688
722
|
const tail = `cursor ${r.cursor[0]},${r.cursor[1]} on a ${r.display.width}x${r.display.height} screen${look.shotTo !== undefined && r.screenshot ? `; screenshot written to ${look.shotTo}` : ''}`;
|
|
689
723
|
out(ctx, `${lines.join('\n')}${lines.length ? '\n' : ''}${tail}\n`, r);
|
|
690
|
-
|
|
724
|
+
if (r.pixel_completion && !r.pixel_completion.matched)
|
|
725
|
+
ctx.io.stderr.write(`Pixel condition failed: ${r.pixel_completion.reason}; input results above say which actions ran.\n`);
|
|
726
|
+
return r.results.every((x) => x.ok) && r.pixel_completion?.matched !== false ? 0 : 1;
|
|
691
727
|
}
|
|
692
728
|
async function computerWorkspace(ctx, ref) {
|
|
693
729
|
const ws = await resolveWorkspace(ctx, ref);
|
|
@@ -748,7 +784,41 @@ function lifecycleText(verb, w, op) {
|
|
|
748
784
|
return `${verb} ${w.key !== undefined ? `${w.key} (${w.id})` : w.id}: operation ${op.id} ${op.kind} ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''}\n${finished ? durableText(op) : `wait with: shard operations wait ${op.id}\n`}`;
|
|
749
785
|
}
|
|
750
786
|
/** The project-key commands; handlers.ts adds the account plane's. */
|
|
787
|
+
function agentGrace(text) {
|
|
788
|
+
if (!/^[0-9]+$/.test(text) || Number(text) > 3600)
|
|
789
|
+
throw new UsageError('--agent-idle-grace-seconds must be 0–3600');
|
|
790
|
+
return Number(text);
|
|
791
|
+
}
|
|
751
792
|
export const KEY_HANDLERS = {
|
|
793
|
+
async 'agents list'(ctx, values) {
|
|
794
|
+
const state = str(values.state);
|
|
795
|
+
if (state && !['working', 'waiting', 'exited'].includes(state))
|
|
796
|
+
throw new UsageError('--state must be working, waiting or exited');
|
|
797
|
+
const labels = {};
|
|
798
|
+
for (const pair of strs(values.label)) {
|
|
799
|
+
const i = pair.indexOf('=');
|
|
800
|
+
if (i < 1)
|
|
801
|
+
throw new UsageError('--label must be key=value');
|
|
802
|
+
labels[pair.slice(0, i)] = pair.slice(i + 1);
|
|
803
|
+
}
|
|
804
|
+
const opts = {
|
|
805
|
+
...(state ? { state: state } : {}), labels,
|
|
806
|
+
...(str(values.limit) ? { limit: parsePositiveInt(str(values.limit), 'limit', 200) } : {}),
|
|
807
|
+
};
|
|
808
|
+
const rows = [];
|
|
809
|
+
let cursor = str(values.cursor);
|
|
810
|
+
let next;
|
|
811
|
+
do {
|
|
812
|
+
const page = await ctx.cloud().agents.list({ ...opts, ...(cursor ? { cursor } : {}) });
|
|
813
|
+
rows.push(...page.data);
|
|
814
|
+
next = page.nextCursor;
|
|
815
|
+
cursor = next ?? undefined;
|
|
816
|
+
} while (values.all === true && cursor);
|
|
817
|
+
out(ctx, rows.length ? table(['WORKSPACE', 'APP', 'STATE', 'TITLE'], rows.map(a => [a.workspace_key, a.app, a.state, a.title])) : 'No agent sessions.\n', { data: rows, next_cursor: next });
|
|
818
|
+
if (!ctx.json && next)
|
|
819
|
+
ctx.io.stderr.write(`more: shard agents list --cursor ${next}\n`);
|
|
820
|
+
return 0;
|
|
821
|
+
},
|
|
752
822
|
async login(ctx) {
|
|
753
823
|
const p = await principal(ctx);
|
|
754
824
|
const where = ctx.apiKey().source === 'env'
|
|
@@ -977,8 +1047,10 @@ export const KEY_HANDLERS = {
|
|
|
977
1047
|
},
|
|
978
1048
|
async 'workspaces open'(ctx, values, [key]) {
|
|
979
1049
|
const template = str(values.template);
|
|
980
|
-
|
|
981
|
-
|
|
1050
|
+
const like = str(values.like);
|
|
1051
|
+
if (!template && !like)
|
|
1052
|
+
throw new UsageError('--template <slug> or --like <id|key> is required for "shard workspaces open"');
|
|
1053
|
+
const volumes = openVolumes(strs(values.volume));
|
|
982
1054
|
const c = workspaceCaps(values);
|
|
983
1055
|
const bind = secretNames(strs(values.secret), '--secret');
|
|
984
1056
|
const lifetime = oneOf(values, 'lifetime', LIFETIMES);
|
|
@@ -994,14 +1066,19 @@ export const KEY_HANDLERS = {
|
|
|
994
1066
|
const cloud = ctx.cloud();
|
|
995
1067
|
// The SDK's waited open: the server holds the request until the workspace is ready (Prefer: wait), then the SDK
|
|
996
1068
|
// polls for what is left of --timeout; one trace (ws.lastTiming) covers all of it.
|
|
1069
|
+
const openSecrets = bind.length ? bind : like ? await agentConfigLikeSecrets(ctx, like) : undefined;
|
|
997
1070
|
const ws = await waited(ctx, 'the open continues server side (open the key again to keep waiting).', () => cloud.workspaces.open({
|
|
998
1071
|
key: key,
|
|
999
|
-
template,
|
|
1072
|
+
...(template ? { template } : {}),
|
|
1073
|
+
...(like ? { like } : {}),
|
|
1074
|
+
...(volumes ? { volumes } : {}),
|
|
1000
1075
|
...(str(values.labels) !== undefined ? { labels: labelMap(str(values.labels)) } : {}),
|
|
1001
1076
|
...(str(values['delete-after-idle']) !== undefined ? { retention: openRetentionDays(str(values['delete-after-idle'])) } : {}),
|
|
1077
|
+
...agentReachability(values),
|
|
1078
|
+
...(str(values['agent-idle-grace-seconds']) !== undefined ? { agentIdleGraceSeconds: agentGrace(str(values['agent-idle-grace-seconds'])) } : {}),
|
|
1002
1079
|
...(str(values['idle-policy']) ? { idlePolicy: str(values['idle-policy']) } : {}),
|
|
1003
1080
|
...(c ? { caps: c } : {}),
|
|
1004
|
-
...(
|
|
1081
|
+
...(openSecrets ? { secrets: openSecrets } : {}),
|
|
1005
1082
|
...(inputs ? { inputs } : {}),
|
|
1006
1083
|
...(lifetime ? { lifetime } : {}),
|
|
1007
1084
|
...(mode ? { mode } : {}),
|
|
@@ -1010,6 +1087,8 @@ export const KEY_HANDLERS = {
|
|
|
1010
1087
|
wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
|
|
1011
1088
|
}));
|
|
1012
1089
|
const op = ws.activeOperation;
|
|
1090
|
+
if (ws.ready && ws.mode === 'processful' && ws.labels.shard_project)
|
|
1091
|
+
await syncAgentConfig(ctx, ws);
|
|
1013
1092
|
const view = ws.data;
|
|
1014
1093
|
const human = ws.ready
|
|
1015
1094
|
? `Workspace ${ws.key} (${ws.id}) is ready.\n${workspaceDetail(view)}`
|
|
@@ -1055,6 +1134,8 @@ export const KEY_HANDLERS = {
|
|
|
1055
1134
|
},
|
|
1056
1135
|
async 'workspaces get'(ctx, _values, [ref]) {
|
|
1057
1136
|
const ws = await resolveWorkspace(ctx, ref);
|
|
1137
|
+
if (!UUID.test(ref))
|
|
1138
|
+
await ws.refresh();
|
|
1058
1139
|
out(ctx, workspaceDetail(ws.data), ws.data);
|
|
1059
1140
|
return 0;
|
|
1060
1141
|
},
|
|
@@ -1177,6 +1258,14 @@ export const KEY_HANDLERS = {
|
|
|
1177
1258
|
}
|
|
1178
1259
|
return code;
|
|
1179
1260
|
},
|
|
1261
|
+
async 'files read-many'(ctx, values, [ref]) {
|
|
1262
|
+
const paths = Array.isArray(values.path) ? values.path.map(String) : typeof values.path === 'string' ? [values.path] : [];
|
|
1263
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
1264
|
+
const result = await cellOf(ctx, ws).files.readSealedMany(paths, { generation: str(values.generation) });
|
|
1265
|
+
const wire = { ...result, items: result.items.map(item => item.data === undefined ? item : { ...item, data: Buffer.from(item.data).toString('base64') }) };
|
|
1266
|
+
ctx.io.stdout.write(json(withTiming(ctx, wire)));
|
|
1267
|
+
return result.complete ? 0 : 1;
|
|
1268
|
+
},
|
|
1180
1269
|
async 'files read'(ctx, values, [ref, path]) {
|
|
1181
1270
|
const ws = await resolveWorkspace(ctx, ref);
|
|
1182
1271
|
const bytes = await cellOf(ctx, ws).files.read(path);
|
|
@@ -1985,6 +2074,13 @@ export const KEY_HANDLERS = {
|
|
|
1985
2074
|
throw new UsageError('<text> must not be empty');
|
|
1986
2075
|
return runComputer(ctx, ref, [{ action: 'type', text }], lookOpts(values));
|
|
1987
2076
|
},
|
|
2077
|
+
async 'workspaces computer paste-text'(ctx, values, [ref, source]) {
|
|
2078
|
+
const text = source === '-' ? Buffer.from(await readInput(ctx, '-', 256 << 10, 'text')).toString('utf8') : source;
|
|
2079
|
+
if (text === '')
|
|
2080
|
+
throw new UsageError('<text> must not be empty');
|
|
2081
|
+
const chord = str(values['paste-chord']);
|
|
2082
|
+
return runComputer(ctx, ref, [{ action: 'pasteText', text, ...(chord !== undefined ? { paste_chord: chord } : {}) }], lookOpts(values));
|
|
2083
|
+
},
|
|
1988
2084
|
async 'workspaces computer key'(ctx, values, [ref, keys]) {
|
|
1989
2085
|
const repeat = str(values.repeat) !== undefined ? parsePositiveInt(str(values.repeat), 'repeat', 100) : undefined;
|
|
1990
2086
|
return runComputer(ctx, ref, [{ action: 'key', text: keys, ...(repeat !== undefined ? { repeat } : {}) }], lookOpts(values));
|
|
@@ -2101,6 +2197,25 @@ export const KEY_HANDLERS = {
|
|
|
2101
2197
|
out(ctx, bindingsText(b), b);
|
|
2102
2198
|
return 0;
|
|
2103
2199
|
},
|
|
2200
|
+
async 'secrets import'(ctx, values, names) {
|
|
2201
|
+
const from = vaultType(str(values.from));
|
|
2202
|
+
const alias = str(values.as);
|
|
2203
|
+
const keychain = str(values.keychain);
|
|
2204
|
+
if (alias && names.length !== 1)
|
|
2205
|
+
throw new UsageError('--as requires exactly one item');
|
|
2206
|
+
if (keychain && from !== 'keychain')
|
|
2207
|
+
throw new UsageError('--keychain applies to the keychain vault');
|
|
2208
|
+
const targets = secretNames(names.map(n => alias ?? n), '--as');
|
|
2209
|
+
const project = (await principal(ctx)).project_id;
|
|
2210
|
+
const imported = [];
|
|
2211
|
+
for (const [i, name] of names.entries()) {
|
|
2212
|
+
const value = await vaultValue(from, name, { signal: ctx.signal, ...(keychain ? { keychain } : {}) });
|
|
2213
|
+
ctx.addSecret(value);
|
|
2214
|
+
imported.push(await ctx.cloud().secrets.create(project, { name: targets[i], value }));
|
|
2215
|
+
}
|
|
2216
|
+
out(ctx, `Imported ${imported.map(s => s.name).join(', ')}. ${SECRET_SECURITY_NOTE}\n`, { data: imported, security_note: SECRET_SECURITY_NOTE });
|
|
2217
|
+
return 0;
|
|
2218
|
+
},
|
|
2104
2219
|
async 'secrets create'(ctx, values, [name]) {
|
|
2105
2220
|
if (!SECRET_NAME.test(name))
|
|
2106
2221
|
throw new UsageError(`<name> must be an environment variable name like OPENAI_API_KEY (^[A-Z_][A-Z0-9_]*$), got "${name}"`);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CredentialRequest, Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
import type { Vault } from './vault.js';
|
|
4
|
+
type Selection = {
|
|
5
|
+
vault: Vault;
|
|
6
|
+
item: string;
|
|
7
|
+
always: boolean;
|
|
8
|
+
fields: CredentialRequest['fields'];
|
|
9
|
+
};
|
|
10
|
+
export declare class SiteVaultStore {
|
|
11
|
+
readonly file: string;
|
|
12
|
+
constructor(directory: string);
|
|
13
|
+
key(api: string, project: string, site: string): string;
|
|
14
|
+
read(): Record<string, Selection>;
|
|
15
|
+
get(api: string, project: string, site: string): Selection | undefined;
|
|
16
|
+
save(api: string, project: string, site: string, selection: Selection): void;
|
|
17
|
+
}
|
|
18
|
+
export declare function approveCredential(ctx: Ctx, request: CredentialRequest, selection: Selection, remember?: boolean): Promise<void>;
|
|
19
|
+
export declare function configureCredentialFill(ctx: Ctx, ws: Workspace): Promise<boolean>;
|
|
20
|
+
export declare function watchCredentialPrompts(ctx: Ctx, ws: Workspace, ask: (message: string) => Promise<string>, signal: AbortSignal): Promise<void>;
|
|
21
|
+
export declare const CREDENTIAL_HANDLERS: Record<string, Handler>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ShardfluxApiError, sealCredential } from '@shardflux/sdk';
|
|
4
|
+
import { resolveWorkspace } from "./commands.js";
|
|
5
|
+
import { str } from "./commands.js";
|
|
6
|
+
import { UsageError } from "./errors.js";
|
|
7
|
+
import { REMEMBERED_SECURITY_NOTE, VAULT_SECURITY_NOTE, vaultCredential, vaultType } from "./vault.js";
|
|
8
|
+
export class SiteVaultStore {
|
|
9
|
+
file;
|
|
10
|
+
constructor(directory) {
|
|
11
|
+
this.file = join(directory, 'site-vaults.json');
|
|
12
|
+
}
|
|
13
|
+
key(api, project, site) {
|
|
14
|
+
return JSON.stringify([api.replace(/\/$/, ''), project, site]);
|
|
15
|
+
}
|
|
16
|
+
read() {
|
|
17
|
+
if (!existsSync(this.file))
|
|
18
|
+
return {};
|
|
19
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
20
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
21
|
+
throw new UsageError('Could not read site vault choices.');
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
get(api, project, site) {
|
|
25
|
+
return this.read()[this.key(api, project, site)];
|
|
26
|
+
}
|
|
27
|
+
save(api, project, site, selection) {
|
|
28
|
+
const all = this.read();
|
|
29
|
+
all[this.key(api, project, site)] = selection;
|
|
30
|
+
mkdirSync(join(this.file, '..'), { recursive: true, mode: 0o700 });
|
|
31
|
+
const temp = `${this.file}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(temp, JSON.stringify(all), { mode: 0o600, flag: 'wx' });
|
|
33
|
+
renameSync(temp, this.file);
|
|
34
|
+
chmodSync(this.file, 0o600);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function approveCredential(ctx, request, selection, remember = false) {
|
|
38
|
+
if (request.state !== 'pending' || Date.parse(request.expires_at) <= Date.now())
|
|
39
|
+
throw new UsageError(`Credential request ${request.id} has expired or ended. ${VAULT_SECURITY_NOTE}`);
|
|
40
|
+
const credential = await vaultCredential(selection.vault, selection.item, request.site, { signal: ctx.signal });
|
|
41
|
+
for (const value of Object.values(credential))
|
|
42
|
+
if (typeof value === 'string' && value)
|
|
43
|
+
ctx.addSecret(value);
|
|
44
|
+
const required = request.fields.map((f) => (f === 'totp' ? 'totp_seed' : f));
|
|
45
|
+
if (required.some((f) => !credential[f]))
|
|
46
|
+
throw new UsageError(`The item does not contain every requested field. ${VAULT_SECURITY_NOTE}`);
|
|
47
|
+
const subset = Object.fromEntries(required.map((f) => [f, credential[f]]));
|
|
48
|
+
const ciphertext = sealCredential(request.public_key, request, subset);
|
|
49
|
+
const answered = await ctx.cloud().credentialRequests.fulfil(request.id, ciphertext);
|
|
50
|
+
if (answered.state !== 'fulfilled')
|
|
51
|
+
throw new UsageError(`Credential request is ${answered.state}. ${VAULT_SECURITY_NOTE}`);
|
|
52
|
+
if (remember)
|
|
53
|
+
await ctx.cloud().credentialRequests.remember(request.workspace_id, request.site, credential);
|
|
54
|
+
new SiteVaultStore(ctx.store.dir).save(ctx.apiUrl, request.project_id, request.site, selection);
|
|
55
|
+
ctx.io.stderr.write(`Approved ${request.site}. ${remember ? REMEMBERED_SECURITY_NOTE : VAULT_SECURITY_NOTE}\n`);
|
|
56
|
+
}
|
|
57
|
+
export async function configureCredentialFill(ctx, ws) {
|
|
58
|
+
try {
|
|
59
|
+
await ctx.cloud().credentialRequests.configure(ws, { signal: ctx.signal });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
if (e instanceof ShardfluxApiError &&
|
|
64
|
+
(e.status === 403 ||
|
|
65
|
+
e.status === 404 ||
|
|
66
|
+
(e.status === 503 && e.details?.reason === 'credential_requests_unavailable')))
|
|
67
|
+
return false;
|
|
68
|
+
// Fill is optional while older guest images are still in use.
|
|
69
|
+
// The explicit credentials enable command continues to report the failure.
|
|
70
|
+
if (e instanceof Error && e.message.startsWith('Credential '))
|
|
71
|
+
return false;
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function watchCredentialPrompts(ctx, ws, ask, signal) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const store = new SiteVaultStore(ctx.store.dir);
|
|
78
|
+
while (!signal.aborted) {
|
|
79
|
+
const page = await ctx.cloud().credentialRequests.list({
|
|
80
|
+
workspaceId: ws.id,
|
|
81
|
+
state: 'pending',
|
|
82
|
+
limit: 200,
|
|
83
|
+
});
|
|
84
|
+
for (const r of page.data) {
|
|
85
|
+
if (signal.aborted)
|
|
86
|
+
return;
|
|
87
|
+
if (seen.has(r.id))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(r.id);
|
|
90
|
+
try {
|
|
91
|
+
const saved = store.get(ctx.apiUrl, r.project_id, r.site);
|
|
92
|
+
if (saved?.always && r.fields.every((f) => saved.fields.includes(f))) {
|
|
93
|
+
await approveCredential(ctx, r, saved);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const choice = (await ask(`${r.site} requests ${r.fields.join(', ')}. Allow once / Always for this project / Deny [once/always/deny]. ${VAULT_SECURITY_NOTE}\n`)).toLowerCase();
|
|
97
|
+
if (choice === 'deny') {
|
|
98
|
+
await ctx.cloud().credentialRequests.deny(r.id);
|
|
99
|
+
ctx.io.stderr.write(`Denied ${r.site}. ${VAULT_SECURITY_NOTE}\n`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (choice !== 'once' && choice !== 'always')
|
|
103
|
+
throw new UsageError(`Choose once, always or deny. ${VAULT_SECURITY_NOTE}`);
|
|
104
|
+
const vault = vaultType(saved?.vault ?? (await ask(`Choose hsec, 1password, bitwarden or keychain. ${VAULT_SECURITY_NOTE}\n`)));
|
|
105
|
+
const item = saved?.item ?? (await ask(`Vault item name for ${r.site}. ${VAULT_SECURITY_NOTE}\n`));
|
|
106
|
+
const remember = (await ask(`Remember for unattended use? [yes/no]. ${REMEMBERED_SECURITY_NOTE}\n`)).toLowerCase() === 'yes';
|
|
107
|
+
await approveCredential(ctx, r, { vault, item, always: choice === 'always', fields: r.fields }, remember);
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
if (signal.aborted)
|
|
111
|
+
return;
|
|
112
|
+
ctx.io.stderr.write(`Credential approval could not finish. ${VAULT_SECURITY_NOTE}\n`);
|
|
113
|
+
if (e instanceof ShardfluxApiError && e.status === 401)
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
await new Promise((resolve) => {
|
|
118
|
+
const done = () => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
signal.removeEventListener('abort', done);
|
|
121
|
+
resolve();
|
|
122
|
+
};
|
|
123
|
+
const timer = setTimeout(done, 5000);
|
|
124
|
+
signal.addEventListener('abort', done, { once: true });
|
|
125
|
+
if (signal.aborted)
|
|
126
|
+
done();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export const CREDENTIAL_HANDLERS = {
|
|
131
|
+
async 'secrets log'(ctx) {
|
|
132
|
+
const events = await ctx.cloud().credentialRequests.events({ limit: 200 });
|
|
133
|
+
ctx.io.stdout.write(ctx.json
|
|
134
|
+
? JSON.stringify({ ...events, security_note: VAULT_SECURITY_NOTE }) + '\n'
|
|
135
|
+
: events.data
|
|
136
|
+
.map((e) => `${e.created_at} ${e.action} ${typeof e.details.site === 'string' ? e.details.site : ''}. ${VAULT_SECURITY_NOTE}\n`)
|
|
137
|
+
.join(''));
|
|
138
|
+
return 0;
|
|
139
|
+
},
|
|
140
|
+
async 'credentials list'(ctx) {
|
|
141
|
+
const page = await ctx.cloud().credentialRequests.list({ state: 'pending', limit: 200 });
|
|
142
|
+
ctx.io.stdout.write(ctx.json
|
|
143
|
+
? JSON.stringify({ ...page, security_note: VAULT_SECURITY_NOTE }) + '\n'
|
|
144
|
+
: page.data.map((r) => `${r.id} ${r.site} ${r.fields.join(', ')}. ${VAULT_SECURITY_NOTE}\n`).join(''));
|
|
145
|
+
return 0;
|
|
146
|
+
},
|
|
147
|
+
async 'credentials enable'(ctx, _values, [ref]) {
|
|
148
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
149
|
+
await ctx.cloud().credentialRequests.configure(ws, { signal: ctx.signal });
|
|
150
|
+
ctx.io.stdout.write(ctx.json
|
|
151
|
+
? JSON.stringify({
|
|
152
|
+
enabled: true,
|
|
153
|
+
workspace_id: ws.id,
|
|
154
|
+
security_note: VAULT_SECURITY_NOTE,
|
|
155
|
+
}) + '\n'
|
|
156
|
+
: `Credential fill enabled for ${ws.key}. ${VAULT_SECURITY_NOTE}\n`);
|
|
157
|
+
return 0;
|
|
158
|
+
},
|
|
159
|
+
async 'credentials approve'(ctx, values, [id]) {
|
|
160
|
+
const { request } = await ctx.cloud().credentialRequests.get(id);
|
|
161
|
+
const vault = vaultType(str(values.from));
|
|
162
|
+
const item = str(values.item);
|
|
163
|
+
if (!item)
|
|
164
|
+
throw new UsageError('--item names a vault login');
|
|
165
|
+
await approveCredential(ctx, request, { vault, item, always: values.always === true, fields: request.fields }, values.remember === true);
|
|
166
|
+
if (ctx.json)
|
|
167
|
+
ctx.io.stdout.write(JSON.stringify({
|
|
168
|
+
approved: true,
|
|
169
|
+
request_id: request.id,
|
|
170
|
+
security_note: values.remember === true ? REMEMBERED_SECURITY_NOTE : VAULT_SECURITY_NOTE,
|
|
171
|
+
}) + '\n');
|
|
172
|
+
return 0;
|
|
173
|
+
},
|
|
174
|
+
async 'credentials deny'(ctx, _values, [id]) {
|
|
175
|
+
const r = await ctx.cloud().credentialRequests.deny(id);
|
|
176
|
+
ctx.io.stdout.write(ctx.json
|
|
177
|
+
? JSON.stringify({ ...r, security_note: VAULT_SECURITY_NOTE }) + '\n'
|
|
178
|
+
: `Credential request ${r.state}. ${VAULT_SECURITY_NOTE}\n`);
|
|
179
|
+
return 0;
|
|
180
|
+
},
|
|
181
|
+
async 'credentials forget'(ctx, _values, [ref, site]) {
|
|
182
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
183
|
+
await ctx.cloud().credentialRequests.forget(ws.id, site);
|
|
184
|
+
ctx.io.stdout.write(ctx.json
|
|
185
|
+
? JSON.stringify({
|
|
186
|
+
forgotten: true,
|
|
187
|
+
site,
|
|
188
|
+
security_note: REMEMBERED_SECURITY_NOTE,
|
|
189
|
+
}) + '\n'
|
|
190
|
+
: `Removed remembered credential for ${site}. ${REMEMBERED_SECURITY_NOTE}\n`);
|
|
191
|
+
return 0;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { UsageError } from "./errors.js";
|
|
2
|
+
import { projectIdentity, normalizeRemote } from "./project-identity.js";
|
|
3
|
+
import { AGENT_HANDLERS } from "./agent-commands.js";
|
|
4
|
+
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
5
|
+
function remote() {
|
|
6
|
+
const project = projectIdentity('./');
|
|
7
|
+
return { project, repoRemote: project.remote ?? normalizeRemote(`file://${project.folder}`) };
|
|
8
|
+
}
|
|
9
|
+
function output(ctx, state) {
|
|
10
|
+
ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${state.level}: ${state.state}; version ${state.version}\n` +
|
|
11
|
+
(state.pending ? `Saving version ${state.pending.version}. Run shard ${state.level} --json for progress.\n` : '') +
|
|
12
|
+
(state.failure ? `${state.failure.message}\n` : '') +
|
|
13
|
+
(state.template ? `${state.template.slug}@${state.template.version}\n` : 'No saved version yet.\n'));
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const rollbackTo = (value) => {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
const n = Number(value);
|
|
20
|
+
if (!Number.isSafeInteger(n) || n < 1)
|
|
21
|
+
throw new UsageError('--to must be a saved version number');
|
|
22
|
+
return n;
|
|
23
|
+
};
|
|
24
|
+
export const FILESYSTEM_HANDLERS = {
|
|
25
|
+
async machine(ctx) {
|
|
26
|
+
const cloud = ctx.cloud();
|
|
27
|
+
const state = await cloud.machine.get({ signal: ctx.signal });
|
|
28
|
+
if (ctx.json || !ctx.io.stdin.isTTY)
|
|
29
|
+
return output(ctx, state);
|
|
30
|
+
const key = `machine/${state.organization_id}/v${state.version}`;
|
|
31
|
+
const ws = await cloud.workspaces.open({ key, template: state.template ? `${state.template.slug}@${state.template.version}` : 'coding',
|
|
32
|
+
labels: { shard_layer: 'machine', shard_machine: state.organization_id, repo_path: '/home/user', app: 'claude', agent: 'machine' },
|
|
33
|
+
wait: { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal } });
|
|
34
|
+
if (state.version === 0)
|
|
35
|
+
await syncAgentConfig(ctx, ws);
|
|
36
|
+
return AGENT_HANDLERS['agents open'](ctx, {}, [ws.key]);
|
|
37
|
+
},
|
|
38
|
+
async 'machine save'(ctx) {
|
|
39
|
+
const cloud = ctx.cloud();
|
|
40
|
+
const state = await cloud.machine.get({ signal: ctx.signal });
|
|
41
|
+
const ws = await cloud.workspaces.findByKey(`machine/${state.organization_id}/v${state.version}`, { signal: ctx.signal }) ??
|
|
42
|
+
(state.workspace_id ? await cloud.workspaces.get(state.workspace_id) : null);
|
|
43
|
+
if (!ws)
|
|
44
|
+
throw new UsageError('Open shard machine, change it, then run shard machine save');
|
|
45
|
+
return output(ctx, await cloud.machine.save({ workspaceId: ws.id }, { signal: ctx.signal }));
|
|
46
|
+
},
|
|
47
|
+
async 'machine rollback'(ctx, values) {
|
|
48
|
+
return output(ctx, await ctx.cloud().machine.rollback({ to: rollbackTo(values.to) }, { signal: ctx.signal }));
|
|
49
|
+
},
|
|
50
|
+
async template(ctx) {
|
|
51
|
+
return output(ctx, await ctx.cloud().templates.getProject(remote().repoRemote, { signal: ctx.signal }));
|
|
52
|
+
},
|
|
53
|
+
async 'template save'(ctx) {
|
|
54
|
+
const { project, repoRemote } = remote();
|
|
55
|
+
const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
|
|
56
|
+
if (!ws)
|
|
57
|
+
throw new UsageError('Run shard ./ for this project before saving its template');
|
|
58
|
+
return output(ctx, await ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, { signal: ctx.signal }));
|
|
59
|
+
},
|
|
60
|
+
async 'template rollback'(ctx, values) {
|
|
61
|
+
return output(ctx, await ctx.cloud().templates.rollbackProject({ repoRemote: remote().repoRemote, to: rollbackTo(values.to) }, { signal: ctx.signal }));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
2
|
+
export async function savedMachine(ctx) {
|
|
3
|
+
try {
|
|
4
|
+
return await ctx.cloud().machine.get({ signal: ctx.signal });
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
// Older APIs have no filesystem route; unmigrated APIs report its dependency.
|
|
8
|
+
if (err instanceof ShardfluxApiError && (err.status === 404 || err.details?.reason === 'filesystem_unavailable'))
|
|
9
|
+
return undefined;
|
|
10
|
+
throw err;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Shardflux } from '@shardflux/sdk';
|
|
3
|
+
import { CredentialStore, configDir } from "./credentials.js";
|
|
4
|
+
import { resolveApiUrl } from "./args.js";
|
|
5
|
+
import { remoteHelper } from "./git-remote.js";
|
|
6
|
+
import { redact } from "./errors.js";
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const abort = () => { controller.abort(); process.stdin.destroy(); };
|
|
9
|
+
process.once('SIGINT', abort);
|
|
10
|
+
process.once('SIGTERM', abort);
|
|
11
|
+
let cell;
|
|
12
|
+
let key = '';
|
|
13
|
+
try {
|
|
14
|
+
const apiUrl = resolveApiUrl(undefined, process.env.SHARDFLUX_API_URL ?? process.env.SHARDFLUX_BASE_URL);
|
|
15
|
+
const store = new CredentialStore(configDir(process.env));
|
|
16
|
+
process.exitCode = await remoteHelper(process.argv[3] ?? process.argv[2] ?? '', process.stdin, process.stdout, process.stderr, async (remote) => {
|
|
17
|
+
key = process.env.SHARDFLUX_API_KEY?.trim() || store.profile(apiUrl).api_key?.token || '';
|
|
18
|
+
if (!key)
|
|
19
|
+
throw new Error('sign in and save a project key with shard setup');
|
|
20
|
+
const cloud = new Shardflux({ apiKey: key, baseUrl: apiUrl, versionCheck: false });
|
|
21
|
+
const ws = await cloud.workspaces.findByKey(remote.key, { includeDeleted: false, signal: controller.signal });
|
|
22
|
+
if (!ws)
|
|
23
|
+
throw new Error(`workspace ${remote.key} does not exist`);
|
|
24
|
+
cell = ws.cell({ agentLabel: 'git-remote-shard', transitionTimeoutMs: 120_000 });
|
|
25
|
+
return cell.exec;
|
|
26
|
+
}, controller.signal);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
process.stderr.write(`git-remote-shard: ${redact(error instanceof Error ? error.message : String(error), key)}\n`);
|
|
30
|
+
process.exitCode = controller.signal.aborted ? 130 : 1;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
cell?.close();
|
|
34
|
+
process.stdin.destroy();
|
|
35
|
+
process.removeListener('SIGINT', abort);
|
|
36
|
+
process.removeListener('SIGTERM', abort);
|
|
37
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git finds a remote helper as `git-remote-<transport>` on PATH. The package exposes only `shard` (a single bin keeps
|
|
3
|
+
* `npx @shardflux/cli` working), so the git commands shard runs get a private directory on PATH holding a
|
|
4
|
+
* `git-remote-shard` shim that runs this installation's helper with the same Node.
|
|
5
|
+
*/
|
|
6
|
+
export declare function gitRemoteHelperDir(env?: NodeJS.ProcessEnv): string;
|
|
7
|
+
/** PATH for git commands shard runs: the helper directory first, then the caller's PATH. */
|
|
8
|
+
export declare function gitPath(env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, extname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/**
|
|
6
|
+
* Git finds a remote helper as `git-remote-<transport>` on PATH. The package exposes only `shard` (a single bin keeps
|
|
7
|
+
* `npx @shardflux/cli` working), so the git commands shard runs get a private directory on PATH holding a
|
|
8
|
+
* `git-remote-shard` shim that runs this installation's helper with the same Node.
|
|
9
|
+
*/
|
|
10
|
+
export function gitRemoteHelperDir(env = process.env) {
|
|
11
|
+
const dir = join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'shardflux', 'git-remote');
|
|
12
|
+
const script = fileURLToPath(new URL(`./git-remote-bin${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
|
|
13
|
+
const quote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
14
|
+
const shim = `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`;
|
|
15
|
+
const target = join(dir, 'git-remote-shard');
|
|
16
|
+
let current = '';
|
|
17
|
+
try {
|
|
18
|
+
current = readFileSync(target, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch { /* first use */ }
|
|
21
|
+
if (current !== shim) {
|
|
22
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
23
|
+
writeFileSync(target, shim, { mode: 0o755 });
|
|
24
|
+
chmodSync(target, 0o755);
|
|
25
|
+
}
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
/** PATH for git commands shard runs: the helper directory first, then the caller's PATH. */
|
|
29
|
+
export function gitPath(env = process.env) {
|
|
30
|
+
return [gitRemoteHelperDir(env), env.PATH].filter(Boolean).join(delimiter);
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CellClient } from '@shardflux/sdk';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
3
|
+
export interface ShardRemote {
|
|
4
|
+
key: string;
|
|
5
|
+
path: string;
|
|
6
|
+
}
|
|
7
|
+
/** Keys have two segments (K9). Everything after the agent is an absolute guest path. */
|
|
8
|
+
export declare function parseShardRemote(url: string): ShardRemote;
|
|
9
|
+
export declare function shardRemote(key: string, path: string): string;
|
|
10
|
+
export type GitExec = Pick<CellClient['exec'], 'start' | 'input' | 'output' | 'cancel'>;
|
|
11
|
+
/** No text decoding of the pack stream; offsets are acknowledged bytes, including partial stdin writes. */
|
|
12
|
+
export declare function bridgeGit(exec: GitExec, service: string, path: string, input: AsyncIterable<Uint8Array>, stdout: Writable, stderr: Writable, signal: AbortSignal): Promise<number>;
|
|
13
|
+
/** Git's `connect` capability switches from newline commands to a full-duplex smart protocol. */
|
|
14
|
+
export declare function remoteHelper(url: string, stdin: Readable, stdout: Writable, stderr: Writable, connect: (remote: ShardRemote) => Promise<GitExec>, signal: AbortSignal): Promise<number>;
|