@shardflux/cli 0.11.0 → 0.13.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/CHANGELOG.md +65 -1
- package/README.md +85 -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 +284 -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.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 +11 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { UsageError } from "./errors.js";
|
|
3
|
+
/** Keys have two segments (K9). Everything after the agent is an absolute guest path. */
|
|
4
|
+
export function parseShardRemote(url) {
|
|
5
|
+
const match = /^shard:\/\/([^/?#]+)\/([^/?#]+)\/(.+)$/.exec(url);
|
|
6
|
+
if (!match)
|
|
7
|
+
throw new UsageError('expected shard://<project>/<agent>/<absolute guest path>');
|
|
8
|
+
const key = `${decodeURIComponent(match[1])}/${decodeURIComponent(match[2])}`;
|
|
9
|
+
const path = `/${decodeURIComponent(match[3])}`;
|
|
10
|
+
if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]{0,63}$/.test(key) || [...path].some(ch => ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127))
|
|
11
|
+
throw new UsageError('invalid shard workspace key or repository path');
|
|
12
|
+
return { key, path };
|
|
13
|
+
}
|
|
14
|
+
export function shardRemote(key, path) {
|
|
15
|
+
if (!path.startsWith('/'))
|
|
16
|
+
throw new UsageError('remote repository path must be absolute');
|
|
17
|
+
const url = `shard://${key}/${path.slice(1).split('/').map(encodeURIComponent).join('/')}`;
|
|
18
|
+
parseShardRemote(url);
|
|
19
|
+
return url;
|
|
20
|
+
}
|
|
21
|
+
async function write(stream, bytes) {
|
|
22
|
+
if (!stream.write(bytes))
|
|
23
|
+
await once(stream, 'drain');
|
|
24
|
+
}
|
|
25
|
+
/** No text decoding of the pack stream; offsets are acknowledged bytes, including partial stdin writes. */
|
|
26
|
+
export async function bridgeGit(exec, service, path, input, stdout, stderr, signal) {
|
|
27
|
+
if (service !== 'git-upload-pack' && service !== 'git-receive-pack')
|
|
28
|
+
throw new UsageError('unsupported Git service');
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const combined = AbortSignal.any([signal, controller.signal]);
|
|
31
|
+
const session = await exec.start({ argv: [service, path], stdin_open: true, timeout_ms: 600_000 }, combined);
|
|
32
|
+
let ended = false, offset = 0;
|
|
33
|
+
const send = async () => {
|
|
34
|
+
for await (const chunk of input) {
|
|
35
|
+
for (let at = 0; at < chunk.length;) {
|
|
36
|
+
combined.throwIfAborted();
|
|
37
|
+
const frame = chunk.subarray(at, at + 65_536);
|
|
38
|
+
const ack = await exec.input(session.session_id, frame, { offset, signal: combined });
|
|
39
|
+
const accepted = ack.offset - offset;
|
|
40
|
+
if (!Number.isSafeInteger(accepted) || accepted <= 0 || accepted > frame.length || ack.closed)
|
|
41
|
+
throw new Error('Git stdin returned an invalid acknowledgement');
|
|
42
|
+
offset = ack.offset;
|
|
43
|
+
at += accepted;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!ended) {
|
|
47
|
+
const ack = await exec.input(session.session_id, new Uint8Array(), { offset, close: true, signal: combined });
|
|
48
|
+
if (ack.offset !== offset || !ack.closed)
|
|
49
|
+
throw new Error('Git stdin did not acknowledge EOF');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const receive = async () => {
|
|
53
|
+
const offsets = { stdout: 0, stderr: 0 };
|
|
54
|
+
const events = await exec.output(session.session_id, { follow: true, signal: combined });
|
|
55
|
+
for await (const event of events) {
|
|
56
|
+
if (event.type === 'output') {
|
|
57
|
+
if (!event.stream || event.offset === undefined || event.data === undefined)
|
|
58
|
+
throw new Error('Git output omitted its stream or byte offset');
|
|
59
|
+
const bytes = Buffer.from(event.data, 'base64');
|
|
60
|
+
const expected = offsets[event.stream];
|
|
61
|
+
if (event.offset > expected)
|
|
62
|
+
throw new Error('Git output has a byte gap');
|
|
63
|
+
const fresh = bytes.subarray(Math.max(0, expected - event.offset));
|
|
64
|
+
if (fresh.length)
|
|
65
|
+
await write(event.stream === 'stdout' ? stdout : stderr, fresh);
|
|
66
|
+
offsets[event.stream] = Math.max(expected, event.offset + bytes.length);
|
|
67
|
+
}
|
|
68
|
+
else if (event.type === 'error')
|
|
69
|
+
throw new Error(`Git exec output failed: ${event.error?.error.message ?? 'stream error'}`);
|
|
70
|
+
else if (event.type === 'exit') {
|
|
71
|
+
ended = true;
|
|
72
|
+
if (!event.session || offsets.stdout !== event.session.stdout_size || offsets.stderr !== event.session.stderr_size)
|
|
73
|
+
throw new Error('Git exec ended before all output arrived');
|
|
74
|
+
return event.session.exit_code ?? 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error('Git exec output ended without an exit event');
|
|
78
|
+
};
|
|
79
|
+
// Observe both promises immediately. Git may exit without closing its input pipe on a failed negotiation.
|
|
80
|
+
const sending = send();
|
|
81
|
+
const receiving = receive();
|
|
82
|
+
try {
|
|
83
|
+
const code = await Promise.race([receiving, sending.then(() => receiving)]);
|
|
84
|
+
return code;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
controller.abort();
|
|
88
|
+
if (!ended)
|
|
89
|
+
await exec.cancel(session.session_id, 1000).catch(() => undefined);
|
|
90
|
+
void sending.catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Git's `connect` capability switches from newline commands to a full-duplex smart protocol. */
|
|
94
|
+
export async function remoteHelper(url, stdin, stdout, stderr, connect, signal) {
|
|
95
|
+
const remote = parseShardRemote(url);
|
|
96
|
+
const iterator = stdin[Symbol.asyncIterator]();
|
|
97
|
+
let pending = Buffer.alloc(0);
|
|
98
|
+
while (true) {
|
|
99
|
+
let newline = pending.indexOf(10);
|
|
100
|
+
while (newline < 0) {
|
|
101
|
+
const next = await iterator.next();
|
|
102
|
+
if (next.done)
|
|
103
|
+
return pending.length ? 1 : 0;
|
|
104
|
+
pending = Buffer.concat([pending, Buffer.from(next.value)]);
|
|
105
|
+
newline = pending.indexOf(10);
|
|
106
|
+
if (newline > 4096 || newline < 0 && pending.length > 4096)
|
|
107
|
+
throw new UsageError('Git helper command is too long');
|
|
108
|
+
}
|
|
109
|
+
const command = pending.subarray(0, newline).toString('utf8');
|
|
110
|
+
pending = pending.subarray(newline + 1);
|
|
111
|
+
if (command === '')
|
|
112
|
+
return 0;
|
|
113
|
+
if (command === 'capabilities')
|
|
114
|
+
await write(stdout, 'connect\n\n');
|
|
115
|
+
else if (command.startsWith('connect ')) {
|
|
116
|
+
const service = command.slice(8);
|
|
117
|
+
if (!['git-upload-pack', 'git-receive-pack'].includes(service))
|
|
118
|
+
throw new UsageError('unsupported Git service');
|
|
119
|
+
const exec = await connect(remote);
|
|
120
|
+
await write(stdout, '\n');
|
|
121
|
+
const input = { async *[Symbol.asyncIterator]() {
|
|
122
|
+
if (pending.length)
|
|
123
|
+
yield pending;
|
|
124
|
+
while (true) {
|
|
125
|
+
const next = await iterator.next();
|
|
126
|
+
if (next.done)
|
|
127
|
+
return;
|
|
128
|
+
yield Buffer.from(next.value);
|
|
129
|
+
}
|
|
130
|
+
} };
|
|
131
|
+
return bridgeGit(exec, service, remote.path, input, stdout, stderr, signal);
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
throw new UsageError(`unsupported Git helper command: ${command}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/handlers.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { CREDENTIAL_HANDLERS } from "./credential-prompts.js";
|
|
1
2
|
/**
|
|
2
3
|
* Every command's handler, keyed by its path ("workspaces open", "auth login"): the project-key commands of
|
|
3
4
|
* commands.ts and the account plane of account-commands.ts (0.5.0). args.test.ts checks that it matches COMMANDS.
|
|
4
5
|
*/
|
|
6
|
+
import { AGENT_CREDENTIAL_HANDLERS } from "./agent-credentials.js";
|
|
5
7
|
import { ACCOUNT_HANDLERS } from "./account-commands.js";
|
|
6
8
|
import { KEY_HANDLERS } from "./commands.js";
|
|
7
|
-
|
|
9
|
+
import { AGENT_HANDLERS } from "./agent-commands.js";
|
|
10
|
+
import { TARGET_HANDLERS } from "./agent-target-commands.js";
|
|
11
|
+
import { CENTER_HANDLERS } from "./command-center-actions.js";
|
|
12
|
+
import { AGENT_GIT_HANDLERS } from "./agent-git-commands.js";
|
|
13
|
+
import { MENUBAR_HANDLERS } from "./menubar.js";
|
|
14
|
+
import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
|
|
15
|
+
export const HANDLERS = { ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS };
|
package/dist/index.d.ts
CHANGED
|
@@ -23,3 +23,5 @@ export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, Execut
|
|
|
23
23
|
export type { ErrorInfo } from './errors.js';
|
|
24
24
|
export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
|
|
25
25
|
export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from './timing.js';
|
|
26
|
+
export { readAgentCredentials, syncAgentCredentials } from './agent-credentials.js';
|
|
27
|
+
export type { CredentialSources } from './agent-credentials.js';
|
package/dist/index.js
CHANGED
|
@@ -15,3 +15,4 @@ export { openUrl, openerFor } from "./browser.js";
|
|
|
15
15
|
export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, ExecutionPendingError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
|
|
16
16
|
export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
|
|
17
17
|
export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from "./timing.js";
|
|
18
|
+
export { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
|
package/dist/input.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface StdinLike extends AsyncIterable<Buffer | string> {
|
|
2
2
|
isTTY?: boolean;
|
|
3
|
+
isRaw?: boolean;
|
|
4
|
+
on?: (event: string, listener: (value: Buffer | string) => void) => unknown;
|
|
5
|
+
off?: (event: string, listener: (value: Buffer | string) => void) => unknown;
|
|
6
|
+
resume?: () => unknown;
|
|
7
|
+
pause?: () => unknown;
|
|
3
8
|
/** tty.ReadStream#setRawMode: needed to read a secret on a terminal without echoing it. */
|
|
4
9
|
setRawMode?: (raw: boolean) => unknown;
|
|
5
10
|
}
|
package/dist/main.js
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
* Every credential the command loaded is redacted from what it prints on failure.
|
|
12
12
|
*/
|
|
13
13
|
import { hostname } from 'node:os';
|
|
14
|
+
import { statSync } from 'node:fs';
|
|
14
15
|
import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
|
|
15
|
-
import { CLI_VERSION, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
|
|
16
|
+
import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
|
|
17
|
+
import { commandCenter } from "./command-center.js";
|
|
18
|
+
import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
|
|
16
19
|
import { CredentialStore, configDir } from "./credentials.js";
|
|
17
20
|
import { AuthConfigError, EXIT, ExecutionPendingError, InterruptedError, UsageError, describeError, exitCodeFor, ownHint, redact } from "./errors.js";
|
|
18
21
|
import { errorText, feedbackCommand, json, time } from "./format.js";
|
|
@@ -398,19 +401,31 @@ export async function run(argv, io, opts = {}) {
|
|
|
398
401
|
};
|
|
399
402
|
let parsed;
|
|
400
403
|
try {
|
|
401
|
-
parsed = parseCommandLine(argv)
|
|
404
|
+
parsed = parseCommandLine(argv, { tty: io.stdin.isTTY === true && io.stdout.isTTY !== false, isProjectPath: (path) => { try {
|
|
405
|
+
return statSync(path).isDirectory();
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return false;
|
|
409
|
+
} } });
|
|
402
410
|
}
|
|
403
411
|
catch (err) {
|
|
404
412
|
return report(err);
|
|
405
413
|
}
|
|
406
414
|
if (parsed.kind === 'help') {
|
|
415
|
+
await startupHousekeeping({ io, store, signal });
|
|
407
416
|
io.stdout.write(parsed.text);
|
|
408
417
|
return EXIT.ok;
|
|
409
418
|
}
|
|
410
419
|
if (parsed.kind === 'version') {
|
|
420
|
+
await startupHousekeeping({ io, store, signal });
|
|
411
421
|
io.stdout.write(wantsJson ? json({ cli: CLI_VERSION, sdk: SDK_VERSION }) : `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n`);
|
|
412
422
|
return EXIT.ok;
|
|
413
423
|
}
|
|
424
|
+
if (parsed.command === PROJECT_COMMAND && (!io.stdin.isTTY || io.stdout.isTTY === false) && !parsed.global.json) {
|
|
425
|
+
await startupHousekeeping({ io, store, signal });
|
|
426
|
+
io.stdout.write(commandHelp(PROJECT_COMMAND));
|
|
427
|
+
return EXIT.ok;
|
|
428
|
+
}
|
|
414
429
|
command = parsed.command.path.join(' ');
|
|
415
430
|
let ctx;
|
|
416
431
|
let updates = null;
|
|
@@ -590,7 +605,7 @@ export async function run(argv, io, opts = {}) {
|
|
|
590
605
|
},
|
|
591
606
|
};
|
|
592
607
|
// The update check runs next to the command (not for `version`, which reports it itself).
|
|
593
|
-
if (!updateCheckDisabled(io.env) && parsed.command.path[0]
|
|
608
|
+
if (!updateCheckDisabled(io.env) && !['version', 'ssh-proxy'].includes(parsed.command.path[0])) {
|
|
594
609
|
try {
|
|
595
610
|
updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
|
|
596
611
|
updates.start();
|
|
@@ -603,13 +618,14 @@ export async function run(argv, io, opts = {}) {
|
|
|
603
618
|
catch (err) {
|
|
604
619
|
return report(err);
|
|
605
620
|
}
|
|
606
|
-
const handler = HANDLERS[parsed.command.path.join(' ')];
|
|
621
|
+
const handler = parsed.command === PROJECT_COMMAND || parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
|
|
607
622
|
if (!handler)
|
|
608
623
|
return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
|
|
624
|
+
await housekeeping(ctx);
|
|
609
625
|
const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
|
|
610
626
|
// On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
|
|
611
627
|
const interrupted = new Promise((resolve) => {
|
|
612
|
-
const cleanupMs = parsed.command.path.join(' ')
|
|
628
|
+
const cleanupMs = ['workspaces tunnel', 'ssh-proxy'].includes(parsed.command.path.join(' ')) ? 90_000 : 4_000;
|
|
613
629
|
const onAbort = () => setTimeout(() => resolve({ interrupted: true }), cleanupMs).unref();
|
|
614
630
|
if (signal.aborted)
|
|
615
631
|
onAbort();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Handler } from './commands.js';
|
|
2
|
+
export type NativeRunner = (tool: string, args: string[]) => Promise<string>;
|
|
3
|
+
export interface MenubarStatus {
|
|
4
|
+
installed: boolean;
|
|
5
|
+
version: string | null;
|
|
6
|
+
running: boolean;
|
|
7
|
+
pids: number[];
|
|
8
|
+
login: 'on' | 'off' | 'requires_approval' | 'not_found';
|
|
9
|
+
}
|
|
10
|
+
type NativeStatus = Omit<MenubarStatus, 'installed' | 'version'>;
|
|
11
|
+
export declare function compareBundleVersions(left: string, right: string): number;
|
|
12
|
+
export declare function parseNativeStatus(text: string): NativeStatus;
|
|
13
|
+
/** Native tools use argument arrays, bounded deadlines, and the installed app's own process/login projection. */
|
|
14
|
+
export declare class MenubarManager {
|
|
15
|
+
#private;
|
|
16
|
+
readonly app: string;
|
|
17
|
+
constructor(options?: {
|
|
18
|
+
home?: string;
|
|
19
|
+
platform?: NodeJS.Platform;
|
|
20
|
+
run?: NativeRunner;
|
|
21
|
+
packageApp?: () => string;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
});
|
|
24
|
+
status(): Promise<MenubarStatus>;
|
|
25
|
+
stop(): Promise<MenubarStatus>;
|
|
26
|
+
login(value: 'on' | 'off'): Promise<MenubarStatus>;
|
|
27
|
+
start(configDir: string, apiUrl: string): Promise<MenubarStatus>;
|
|
28
|
+
}
|
|
29
|
+
/** Only the command center consumes this hint. It reads status, never resolves or installs the optional package. */
|
|
30
|
+
export declare function menubarHint(dir: string, manager?: MenubarManager): Promise<string | undefined>;
|
|
31
|
+
export declare const MENUBAR_HANDLERS: Record<string, Handler>;
|
|
32
|
+
export {};
|
package/dist/menubar.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
8
|
+
import { UsageError } from "./errors.js";
|
|
9
|
+
import { json } from "./format.js";
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const BUNDLE_ID = 'dev.shardflux.menubar';
|
|
12
|
+
export function compareBundleVersions(left, right) {
|
|
13
|
+
if (![left, right].every(v => /^\d+(\.\d+){0,3}$/.test(v)))
|
|
14
|
+
throw new UsageError('Shardflux app has an invalid bundle version.');
|
|
15
|
+
const a = left.split('.').map(Number), b = right.split('.').map(Number);
|
|
16
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
17
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
18
|
+
return (a[i] ?? 0) < (b[i] ?? 0) ? -1 : 1;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
export function parseNativeStatus(text) {
|
|
23
|
+
const value = JSON.parse(text);
|
|
24
|
+
if (!value || typeof value !== 'object')
|
|
25
|
+
throw new UsageError('Could not read Shardflux app status.');
|
|
26
|
+
const row = value;
|
|
27
|
+
if (typeof row.running !== 'boolean' || !Array.isArray(row.pids) || !row.pids.every(n => Number.isInteger(n) && n > 0) ||
|
|
28
|
+
!['on', 'off', 'requires_approval', 'not_found'].includes(String(row.login)) || row.running !== (row.pids.length > 0)) {
|
|
29
|
+
throw new UsageError('Could not read Shardflux app status.');
|
|
30
|
+
}
|
|
31
|
+
return row;
|
|
32
|
+
}
|
|
33
|
+
/** Native tools use argument arrays, bounded deadlines, and the installed app's own process/login projection. */
|
|
34
|
+
export class MenubarManager {
|
|
35
|
+
app;
|
|
36
|
+
#run;
|
|
37
|
+
#platform;
|
|
38
|
+
#packageApp;
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
this.app = join(options.home ?? homedir(), 'Applications', 'Shardflux.app');
|
|
41
|
+
this.#platform = options.platform ?? process.platform;
|
|
42
|
+
this.#run = options.run ?? (async (tool, args) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal: options.signal })).stdout);
|
|
43
|
+
this.#packageApp = options.packageApp ?? (() => {
|
|
44
|
+
try {
|
|
45
|
+
return join(dirname(createRequire(import.meta.url).resolve('@shardflux/menubar-darwin/package.json')), 'Shardflux.app');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new UsageError('The macOS app package is missing. Reinstall shard with optional dependencies enabled, then run shard menubar start.');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
#requireMac() { if (this.#platform !== 'darwin')
|
|
53
|
+
throw new UsageError('shard menubar requires macOS 14 or later.'); }
|
|
54
|
+
async #version(app) {
|
|
55
|
+
const file = join(app, 'Contents', 'Info.plist');
|
|
56
|
+
const id = (await this.#run('/usr/bin/plutil', ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', file])).trim();
|
|
57
|
+
if (id !== BUNDLE_ID || lstatSync(app).isSymbolicLink())
|
|
58
|
+
throw new UsageError(`Refusing to replace an unrelated app at ${app}.`);
|
|
59
|
+
const version = (await this.#run('/usr/bin/plutil', ['-extract', 'CFBundleVersion', 'raw', '-o', '-', file])).trim();
|
|
60
|
+
compareBundleVersions(version, version);
|
|
61
|
+
return version;
|
|
62
|
+
}
|
|
63
|
+
async #control(action, ...args) {
|
|
64
|
+
return parseNativeStatus(await this.#run(join(this.app, 'Contents', 'MacOS', 'ShardfluxMenuBar'), ['--control', action, ...args]));
|
|
65
|
+
}
|
|
66
|
+
async status() {
|
|
67
|
+
this.#requireMac();
|
|
68
|
+
if (!existsSync(this.app))
|
|
69
|
+
return { installed: false, version: null, running: false, pids: [], login: 'off' };
|
|
70
|
+
const version = await this.#version(this.app);
|
|
71
|
+
return { installed: true, version, ...await this.#control('status') };
|
|
72
|
+
}
|
|
73
|
+
async #stripQuarantine() {
|
|
74
|
+
const attributes = await this.#run('/usr/bin/xattr', ['-lr', this.app]);
|
|
75
|
+
if (attributes.includes('com.apple.quarantine'))
|
|
76
|
+
await this.#run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', this.app]);
|
|
77
|
+
}
|
|
78
|
+
async stop() {
|
|
79
|
+
const current = await this.status();
|
|
80
|
+
if (!current.installed || !current.running)
|
|
81
|
+
return current;
|
|
82
|
+
await this.#control('stop');
|
|
83
|
+
for (let i = 0; i < 20; i++) {
|
|
84
|
+
const status = await this.status();
|
|
85
|
+
if (!status.running)
|
|
86
|
+
return status;
|
|
87
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
88
|
+
}
|
|
89
|
+
throw new UsageError('Shardflux is still stopping. Run shard menubar status before retrying.');
|
|
90
|
+
}
|
|
91
|
+
async login(value) {
|
|
92
|
+
if (!(await this.status()).installed)
|
|
93
|
+
throw new UsageError('Run shard menubar start before changing launch at login.');
|
|
94
|
+
await this.#control('login', value);
|
|
95
|
+
return this.status();
|
|
96
|
+
}
|
|
97
|
+
async start(configDir, apiUrl) {
|
|
98
|
+
this.#requireMac();
|
|
99
|
+
const source = this.#packageApp(), sourceVersion = await this.#version(source);
|
|
100
|
+
const current = await this.status();
|
|
101
|
+
if (!current.installed || compareBundleVersions(current.version, sourceVersion) < 0) {
|
|
102
|
+
// Verify the source before stopping or replacing any existing app.
|
|
103
|
+
await this.#run('/usr/bin/codesign', ['--verify', '--deep', '--strict', source]);
|
|
104
|
+
mkdirSync(dirname(this.app), { recursive: true });
|
|
105
|
+
const stage = mkdtempSync(join(dirname(this.app), '.Shardflux-install-'));
|
|
106
|
+
const staged = join(stage, 'Shardflux.app'), backup = join(stage, 'previous.app');
|
|
107
|
+
let committed = false, restored = false;
|
|
108
|
+
try {
|
|
109
|
+
cpSync(source, staged, { recursive: true, dereference: false });
|
|
110
|
+
if (current.running)
|
|
111
|
+
await this.stop();
|
|
112
|
+
if (current.installed)
|
|
113
|
+
renameSync(this.app, backup);
|
|
114
|
+
try {
|
|
115
|
+
renameSync(staged, this.app);
|
|
116
|
+
committed = true;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (existsSync(backup)) {
|
|
120
|
+
try {
|
|
121
|
+
renameSync(backup, this.app);
|
|
122
|
+
restored = true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new UsageError(`Could not restore the previous app. It is preserved at ${backup}.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
if (committed || restored || !existsSync(backup))
|
|
133
|
+
rmSync(stage, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await this.#stripQuarantine();
|
|
137
|
+
if (!(await this.status()).running) {
|
|
138
|
+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
139
|
+
const log = join(configDir, 'menubar.log');
|
|
140
|
+
await this.#run('/usr/bin/open', ['-g', this.app, '--env', `SHARDFLUX_CONFIG_DIR=${configDir}`, '--env', `SHARDFLUX_API_URL=${apiUrl}`, '--stdout', log, '--stderr', log]);
|
|
141
|
+
}
|
|
142
|
+
for (let i = 0; i < 20; i++) {
|
|
143
|
+
const status = await this.status();
|
|
144
|
+
if (status.running)
|
|
145
|
+
return status;
|
|
146
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
147
|
+
}
|
|
148
|
+
throw new UsageError(`Shardflux did not start. Check ${join(configDir, 'menubar.log')}.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Only the command center consumes this hint. It reads status, never resolves or installs the optional package. */
|
|
152
|
+
export async function menubarHint(dir, manager = new MenubarManager()) {
|
|
153
|
+
if (process.platform !== 'darwin')
|
|
154
|
+
return undefined;
|
|
155
|
+
const store = new AgentStateStore(dir);
|
|
156
|
+
if (store.read().menubar_hint_shown === true)
|
|
157
|
+
return undefined;
|
|
158
|
+
try {
|
|
159
|
+
if ((await manager.status()).running)
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
store.update(doc => { doc.menubar_hint_shown = true; });
|
|
166
|
+
return 'Menu bar: shard menubar start';
|
|
167
|
+
}
|
|
168
|
+
export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].map(action => {
|
|
169
|
+
const handler = async (ctx, values) => {
|
|
170
|
+
if (values.login !== undefined && !['on', 'off'].includes(String(values.login)))
|
|
171
|
+
throw new UsageError('--login must be on or off.');
|
|
172
|
+
const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
|
|
173
|
+
let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop() : await manager.status();
|
|
174
|
+
if (values.login === 'on' || values.login === 'off')
|
|
175
|
+
status = await manager.login(values.login);
|
|
176
|
+
ctx.io.stdout.write(ctx.json ? json(status) : `Menu bar: ${status.running ? 'running' : 'stopped'}${status.installed ? ` · app ${status.version}` : ' · not installed'} · login ${status.login}\n`);
|
|
177
|
+
return 0;
|
|
178
|
+
};
|
|
179
|
+
return [`menubar ${action}`, handler];
|
|
180
|
+
}));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Transport and credentials are not project identity. Keep repository path case on case-sensitive hosts. */
|
|
2
|
+
export declare function normalizeRemote(remote: string, folder?: string): string;
|
|
3
|
+
export declare function projectSlug(remote: string | null, folder: string): string;
|
|
4
|
+
export declare function projectIdentity(path: string): {
|
|
5
|
+
folder: string;
|
|
6
|
+
slug: string;
|
|
7
|
+
remote: string | null;
|
|
8
|
+
};
|
|
9
|
+
export declare function projectLabels(project: ReturnType<typeof projectIdentity>, agent: string, app: 'claude' | 'codex'): {
|
|
10
|
+
shard_project: string;
|
|
11
|
+
repo_remote: string;
|
|
12
|
+
agent: string;
|
|
13
|
+
app: "claude" | "codex";
|
|
14
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { statSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { UsageError } from "./errors.js";
|
|
5
|
+
/** Transport and credentials are not project identity. Keep repository path case on case-sensitive hosts. */
|
|
6
|
+
export function normalizeRemote(remote, folder = process.cwd()) {
|
|
7
|
+
const raw = remote.trim();
|
|
8
|
+
const scp = /^(?:[^/@:]+@)?([^/:]+):(.+)$/.exec(raw);
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(scp && !raw.includes('://') ? `ssh://${scp[1]}/${scp[2]}` : raw);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
if (raw.includes('://'))
|
|
15
|
+
throw new UsageError('git remote URL is invalid');
|
|
16
|
+
return `file://${resolve(folder, raw).replace(/\/+$/, '').replace(/\.git$/, '')}`;
|
|
17
|
+
}
|
|
18
|
+
if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol))
|
|
19
|
+
throw new UsageError('unsupported git remote transport');
|
|
20
|
+
if (url.protocol === 'file:')
|
|
21
|
+
return `file://${url.pathname.replace(/\/+$/, '').replace(/\.git$/, '')}`;
|
|
22
|
+
const host = url.hostname.toLowerCase();
|
|
23
|
+
const port = url.port && !((url.protocol === 'ssh:' && url.port === '22') || (url.protocol === 'git:' && url.port === '9418')) ? `:${url.port}` : '';
|
|
24
|
+
let path = url.pathname.replace(/\/+$/, '').replace(/\.git$/, '').replace(/^\/+/, '');
|
|
25
|
+
if (host === 'github.com' || host === 'bitbucket.org')
|
|
26
|
+
path = path.toLowerCase();
|
|
27
|
+
return `https://${host}${port}/${path}`;
|
|
28
|
+
}
|
|
29
|
+
export function projectSlug(remote, folder) {
|
|
30
|
+
const name = remote ? remote.split('/').at(-1) : basename(folder);
|
|
31
|
+
const slug = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '').slice(0, 96);
|
|
32
|
+
if (!slug)
|
|
33
|
+
throw new UsageError('project name has no letters or digits; use a named project folder');
|
|
34
|
+
return slug;
|
|
35
|
+
}
|
|
36
|
+
export function projectIdentity(path) {
|
|
37
|
+
let folder;
|
|
38
|
+
try {
|
|
39
|
+
folder = realpathSync(resolve(path));
|
|
40
|
+
if (!statSync(folder).isDirectory())
|
|
41
|
+
throw new Error('not a directory');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new UsageError('project path must name an accessible directory');
|
|
45
|
+
}
|
|
46
|
+
let raw = null;
|
|
47
|
+
try {
|
|
48
|
+
raw = execFileSync('git', ['-C', folder, 'remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A folder without an origin is a project too.
|
|
52
|
+
}
|
|
53
|
+
const remote = raw ? normalizeRemote(raw, folder) : null;
|
|
54
|
+
const slug = projectSlug(remote, folder);
|
|
55
|
+
return { folder, slug, remote };
|
|
56
|
+
}
|
|
57
|
+
export function projectLabels(project, agent, app) {
|
|
58
|
+
if (project.remote && project.remote.length > 256)
|
|
59
|
+
throw new UsageError('normalized repository remote exceeds the 256-character workspace label limit');
|
|
60
|
+
return { shard_project: project.slug, repo_remote: project.remote ?? '', agent, app };
|
|
61
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FileManifest, FileManifestEntry } from '@shardflux/sdk';
|
|
2
|
+
export declare const MAX_SYNC_FILE_BYTES: number;
|
|
3
|
+
export declare const MAX_SYNC_ENTRIES = 20000;
|
|
4
|
+
export type Manifest = Record<string, FileManifestEntry>;
|
|
5
|
+
export type Common = Record<string, string>;
|
|
6
|
+
export interface SyncChange {
|
|
7
|
+
path: string;
|
|
8
|
+
winner: 'local' | 'remote';
|
|
9
|
+
conflict: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function validSyncPath(path: string): boolean;
|
|
12
|
+
export declare function excludedSyncPath(path: string): boolean;
|
|
13
|
+
export declare function contentIdentity(entry?: FileManifestEntry): string;
|
|
14
|
+
/** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
|
|
15
|
+
export declare function diffManifests(local: Manifest, remote: Manifest, common: Common, observedAt?: number, blocked?: Set<string>): SyncChange[];
|
|
16
|
+
export declare function parseManifest(manifest: FileManifest): Manifest;
|
|
17
|
+
export declare function projectGit(folder: string, args: string[], signal?: AbortSignal, input?: string): Promise<string>;
|
|
18
|
+
/** Uses the actual Git implementation rather than an approximation of gitignore. */
|
|
19
|
+
export declare function selectedProjectPaths(folder: string, signal?: AbortSignal): Promise<string[]>;
|
|
20
|
+
export declare function ignoredProjectPaths(folder: string, paths: string[], signal?: AbortSignal): Promise<string[]>;
|
|
21
|
+
/** Refuse parent symlinks, including a tracked file beneath a submodule/link. */
|
|
22
|
+
export declare function projectFilePath(folder: string, path: string): Promise<string>;
|
|
23
|
+
export type LocalHashCache = Map<string, {
|
|
24
|
+
metadata: string;
|
|
25
|
+
entry: FileManifestEntry;
|
|
26
|
+
}>;
|
|
27
|
+
export declare function localManifest(folder: string, signal?: AbortSignal, cache?: LocalHashCache, known?: string[]): Promise<FileManifest>;
|