@shardflux/cli 0.14.4 → 0.15.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 +66 -0
- package/README.md +73 -11
- package/dist/account-args.js +28 -7
- package/dist/account-commands.js +140 -11
- package/dist/acp-catalog.js +2 -2
- package/dist/acp-login.d.ts +20 -0
- package/dist/acp-login.js +122 -0
- package/dist/acp.js +36 -13
- package/dist/agent-commands.js +58 -6
- package/dist/agent-git-commands.js +2 -1
- package/dist/agent-reachability.js +1 -1
- package/dist/agent-target-commands.js +26 -1
- package/dist/agent-targets.d.ts +7 -0
- package/dist/agent-targets.js +44 -32
- package/dist/agent-terminal.d.ts +24 -2
- package/dist/agent-terminal.js +82 -4
- package/dist/args.d.ts +3 -1
- package/dist/args.js +21 -9
- package/dist/browser-login.d.ts +35 -0
- package/dist/browser-login.js +145 -0
- package/dist/claude-cloud-login.js +2 -2
- package/dist/codex-cloud.d.ts +49 -0
- package/dist/codex-cloud.js +157 -0
- package/dist/command-center-actions.js +7 -0
- package/dist/command-center-renderer.d.ts +3 -0
- package/dist/command-center-renderer.js +42 -38
- package/dist/command-center.js +65 -11
- package/dist/conversation-terminal.js +19 -0
- package/dist/conversations.d.ts +1 -1
- package/dist/conversations.js +21 -8
- package/dist/credential-prompts.d.ts +4 -2
- package/dist/credential-prompts.js +38 -16
- package/dist/device-bridge.d.ts +13 -0
- package/dist/device-bridge.js +14 -4
- package/dist/device-helper.js +2 -1
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +15 -2
- package/dist/handlers.js +2 -1
- package/dist/initial-prompt.d.ts +26 -0
- package/dist/initial-prompt.js +196 -0
- package/dist/legal.d.ts +36 -0
- package/dist/legal.js +44 -0
- package/dist/main.js +6 -2
- package/dist/menubar.d.ts +8 -0
- package/dist/menubar.js +9 -1
- package/dist/onboarding-app/AGENTS.md +19 -0
- package/dist/onboarding-app/CLAUDE.md +19 -0
- package/dist/onboarding-app/README.md +12 -0
- package/dist/onboarding-app/dev.mjs +18 -0
- package/dist/onboarding-app/index.html +25 -0
- package/dist/onboarding-app/package.json +12 -0
- package/dist/onboarding-app/server.mjs +41 -0
- package/dist/onboarding-sample.d.ts +3 -0
- package/dist/onboarding-sample.js +29 -0
- package/dist/onboarding.d.ts +258 -0
- package/dist/onboarding.js +371 -0
- package/dist/terminal-brand.d.ts +5 -0
- package/dist/terminal-brand.js +18 -0
- package/package.json +3 -3
|
@@ -21,12 +21,20 @@ export class SiteVaultStore {
|
|
|
21
21
|
throw new UsageError('Could not read site vault choices.');
|
|
22
22
|
return raw;
|
|
23
23
|
}
|
|
24
|
-
get(api, project, site) {
|
|
25
|
-
|
|
24
|
+
get(api, project, site, profile) {
|
|
25
|
+
const all = this.read();
|
|
26
|
+
return all[this.key(api, project, site)] ?? (profile ? all[this.key(api, `machine:${profile}`, site)] : undefined);
|
|
26
27
|
}
|
|
27
|
-
save(api, project, site, selection) {
|
|
28
|
+
save(api, project, site, selection, profile) {
|
|
28
29
|
const all = this.read();
|
|
29
|
-
|
|
30
|
+
if (selection.scope === 'machine') {
|
|
31
|
+
if (!profile)
|
|
32
|
+
throw new UsageError('Sign in or save a project profile before choosing machine scope.');
|
|
33
|
+
delete all[this.key(api, project, site)];
|
|
34
|
+
all[this.key(api, `machine:${profile}`, site)] = selection;
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
all[this.key(api, project, site)] = selection;
|
|
30
38
|
mkdirSync(join(this.file, '..'), { recursive: true, mode: 0o700 });
|
|
31
39
|
const temp = `${this.file}.${process.pid}.tmp`;
|
|
32
40
|
writeFileSync(temp, JSON.stringify(all), { mode: 0o600, flag: 'wx' });
|
|
@@ -34,9 +42,20 @@ export class SiteVaultStore {
|
|
|
34
42
|
chmodSync(this.file, 0o600);
|
|
35
43
|
}
|
|
36
44
|
}
|
|
45
|
+
function vaultProfile(ctx) {
|
|
46
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
47
|
+
return profile.organization_id ? `org:${profile.organization_id}${profile.session?.user?.id ? `|user:${profile.session.user.id}` : ""}` : profile.session?.user?.id ? `user:${profile.session.user.id}` : profile.api_key?.key_id ? `key:${profile.api_key.key_id}` : undefined;
|
|
48
|
+
}
|
|
49
|
+
function approvalScope(value) {
|
|
50
|
+
if (value !== undefined && !['ask', 'project', 'machine'].includes(value))
|
|
51
|
+
throw new UsageError('--scope must be ask, project or machine');
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
37
54
|
export async function approveCredential(ctx, request, selection, remember = false) {
|
|
38
55
|
if (request.state !== 'pending' || Date.parse(request.expires_at) <= Date.now())
|
|
39
56
|
throw new UsageError(`Credential request ${request.id} has expired or ended. ${VAULT_SECURITY_NOTE}`);
|
|
57
|
+
if (selection.scope === 'machine' && !vaultProfile(ctx))
|
|
58
|
+
throw new UsageError('Sign in or save a project profile before choosing machine scope.');
|
|
40
59
|
const credential = await vaultCredential(selection.vault, selection.item, request.site, { signal: ctx.signal });
|
|
41
60
|
for (const value of Object.values(credential))
|
|
42
61
|
if (typeof value === 'string' && value)
|
|
@@ -50,8 +69,8 @@ export async function approveCredential(ctx, request, selection, remember = fals
|
|
|
50
69
|
if (answered.state !== 'fulfilled')
|
|
51
70
|
throw new UsageError(`Credential request is ${answered.state}. ${VAULT_SECURITY_NOTE}`);
|
|
52
71
|
if (remember)
|
|
53
|
-
await ctx.cloud().credentialRequests.remember(request.workspace_id, request.site,
|
|
54
|
-
new SiteVaultStore(ctx.store.dir).save(ctx.apiUrl, request.project_id, request.site, selection);
|
|
72
|
+
await ctx.cloud().credentialRequests.remember(request.workspace_id, request.site, subset, selection.scope ? { scope: selection.scope === 'ask' ? 'conversation' : selection.scope } : {});
|
|
73
|
+
new SiteVaultStore(ctx.store.dir).save(ctx.apiUrl, request.project_id, request.site, { ...selection, always: selection.scope ? selection.scope !== 'ask' : selection.always, remember }, vaultProfile(ctx));
|
|
55
74
|
ctx.io.stderr.write(`Approved ${request.site}. ${remember ? REMEMBERED_SECURITY_NOTE : VAULT_SECURITY_NOTE}\n`);
|
|
56
75
|
}
|
|
57
76
|
export async function configureCredentialFill(ctx, ws) {
|
|
@@ -88,23 +107,23 @@ export async function watchCredentialPrompts(ctx, ws, ask, signal) {
|
|
|
88
107
|
continue;
|
|
89
108
|
seen.add(r.id);
|
|
90
109
|
try {
|
|
91
|
-
const saved = store.get(ctx.apiUrl, r.project_id, r.site);
|
|
92
|
-
if (saved
|
|
93
|
-
await approveCredential(ctx, r, saved);
|
|
110
|
+
const saved = store.get(ctx.apiUrl, r.project_id, r.site, vaultProfile(ctx));
|
|
111
|
+
if (saved && (saved.scope ? saved.scope !== 'ask' : saved.always) && r.fields.every((f) => saved.fields.includes(f))) {
|
|
112
|
+
await approveCredential(ctx, r, saved, saved.remember);
|
|
94
113
|
continue;
|
|
95
114
|
}
|
|
96
|
-
const choice = (await ask(`${r.site} requests ${r.fields.join(', ')}.
|
|
115
|
+
const choice = (await ask(`${r.site} requests ${r.fields.join(', ')}. Ask every time / Allow for this project / Allow on my machine / Deny [once/project/machine/deny]. ${VAULT_SECURITY_NOTE}\n`)).toLowerCase();
|
|
97
116
|
if (choice === 'deny') {
|
|
98
117
|
await ctx.cloud().credentialRequests.deny(r.id);
|
|
99
118
|
ctx.io.stderr.write(`Denied ${r.site}. ${VAULT_SECURITY_NOTE}\n`);
|
|
100
119
|
continue;
|
|
101
120
|
}
|
|
102
|
-
if (
|
|
103
|
-
throw new UsageError(`Choose once,
|
|
121
|
+
if (!['once', 'always', 'project', 'machine'].includes(choice))
|
|
122
|
+
throw new UsageError(`Choose once, project, machine or deny. ${VAULT_SECURITY_NOTE}`);
|
|
104
123
|
const vault = vaultType(saved?.vault ?? (await ask(`Choose hsec, 1password, bitwarden or keychain. ${VAULT_SECURITY_NOTE}\n`)));
|
|
105
124
|
const item = saved?.item ?? (await ask(`Vault item name for ${r.site}. ${VAULT_SECURITY_NOTE}\n`));
|
|
106
125
|
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 === '
|
|
126
|
+
await approveCredential(ctx, r, { vault, item, always: choice !== 'once', scope: choice === 'once' ? 'ask' : choice === 'machine' ? 'machine' : 'project', fields: r.fields }, remember);
|
|
108
127
|
}
|
|
109
128
|
catch (e) {
|
|
110
129
|
if (signal.aborted)
|
|
@@ -162,7 +181,7 @@ export const CREDENTIAL_HANDLERS = {
|
|
|
162
181
|
const item = str(values.item);
|
|
163
182
|
if (!item)
|
|
164
183
|
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);
|
|
184
|
+
await approveCredential(ctx, request, { vault, item, always: values.always === true || ['project', 'machine'].includes(str(values.scope) ?? ''), scope: approvalScope(str(values.scope)), fields: request.fields }, values.remember === true);
|
|
166
185
|
if (ctx.json)
|
|
167
186
|
ctx.io.stdout.write(JSON.stringify({
|
|
168
187
|
approved: true,
|
|
@@ -178,9 +197,12 @@ export const CREDENTIAL_HANDLERS = {
|
|
|
178
197
|
: `Credential request ${r.state}. ${VAULT_SECURITY_NOTE}\n`);
|
|
179
198
|
return 0;
|
|
180
199
|
},
|
|
181
|
-
async 'credentials forget'(ctx,
|
|
200
|
+
async 'credentials forget'(ctx, values, [ref, site]) {
|
|
182
201
|
const ws = await resolveWorkspace(ctx, ref);
|
|
183
|
-
|
|
202
|
+
const scope = str(values.scope);
|
|
203
|
+
if (scope && !['conversation', 'project', 'machine'].includes(scope))
|
|
204
|
+
throw new UsageError('--scope must be conversation, project or machine');
|
|
205
|
+
await ctx.cloud().credentialRequests.forget(ws.id, site, { scope: scope });
|
|
184
206
|
ctx.io.stdout.write(ctx.json
|
|
185
207
|
? JSON.stringify({
|
|
186
208
|
forgotten: true,
|
package/dist/device-bridge.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface DeviceApproval {
|
|
|
31
31
|
conversation_id: string;
|
|
32
32
|
summary: string;
|
|
33
33
|
outside: boolean;
|
|
34
|
+
kind?: DeviceRequest['kind'];
|
|
35
|
+
argv?: string[];
|
|
36
|
+
local_path?: string;
|
|
37
|
+
remote_path?: string;
|
|
38
|
+
project_name?: string;
|
|
39
|
+
conversation_title?: string;
|
|
40
|
+
local_port?: number;
|
|
41
|
+
remote_port?: number;
|
|
34
42
|
}
|
|
35
43
|
type Result = {
|
|
36
44
|
state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
|
|
@@ -56,6 +64,11 @@ export declare class DeviceGrants {
|
|
|
56
64
|
save(api: string, project: string, root: string, kind: string): Promise<void>;
|
|
57
65
|
}
|
|
58
66
|
export declare function deviceSummary(r: DeviceRequest): string;
|
|
67
|
+
/** Structured local approval details; the summary remains for older menu bar and ACP clients. */
|
|
68
|
+
export declare function deviceApproval(r: DeviceRequest, c: Conversation, target: {
|
|
69
|
+
path: string;
|
|
70
|
+
outside: boolean;
|
|
71
|
+
}, folder: string): DeviceApproval;
|
|
59
72
|
export declare function tryLocallyAction(r: DeviceRequest): 'start' | 'stop' | null;
|
|
60
73
|
export declare function ensureConversationWorktree(ctx: Ctx, c: Conversation, folder: string, ws?: Workspace): Promise<string>;
|
|
61
74
|
export declare function deviceExec(argv: string[], cwd: string, timeoutSeconds?: number, signal?: AbortSignal): Promise<Result>;
|
package/dist/device-bridge.js
CHANGED
|
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
|
|
|
3
3
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
4
4
|
import { readFile, writeFile, mkdir, realpath, stat } from 'node:fs/promises';
|
|
5
5
|
import { createConnection, createServer } from 'node:net';
|
|
6
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
7
|
import { WorkspaceTunnels } from '@shardflux/sdk';
|
|
8
8
|
import { writePrivateFile } from "./credentials.js";
|
|
9
9
|
import { conversationName, conversationWorkspace, getConversation, localConversations } from "./conversations.js";
|
|
@@ -73,8 +73,18 @@ export function deviceSummary(r) {
|
|
|
73
73
|
if (r.kind === 'exec')
|
|
74
74
|
return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
|
|
75
75
|
if (r.files)
|
|
76
|
-
return `${r.kind === 'files_pull' ? '
|
|
77
|
-
return `${r.kind === 'port_forward' ? 'Open
|
|
76
|
+
return `${r.kind === 'files_pull' ? 'Share with conversation' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
|
|
77
|
+
return `${r.kind === 'port_forward' ? 'Open conversation preview on your Mac' : 'Connect conversation to your Mac'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
|
|
78
|
+
}
|
|
79
|
+
/** Structured local approval details; the summary remains for older menu bar and ACP clients. */
|
|
80
|
+
export function deviceApproval(r, c, target, folder) {
|
|
81
|
+
return {
|
|
82
|
+
type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id,
|
|
83
|
+
summary: deviceSummary(r), outside: target.outside, kind: r.kind, local_path: target.path,
|
|
84
|
+
project_name: basename(folder), conversation_title: c.title || conversationName(c),
|
|
85
|
+
...(r.exec ? { argv: r.exec.argv } : {}), ...(r.files ? { remote_path: r.files.remote_path } : {}),
|
|
86
|
+
...(r.port ? { local_port: r.port.local_port, remote_port: r.port.remote_port } : {}),
|
|
87
|
+
};
|
|
78
88
|
}
|
|
79
89
|
export function tryLocallyAction(r) {
|
|
80
90
|
if (r.kind === 'try_locally')
|
|
@@ -172,7 +182,7 @@ export class DeviceExecutor {
|
|
|
172
182
|
const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
|
|
173
183
|
const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
|
|
174
184
|
target.outside ||= planned.outside;
|
|
175
|
-
const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask(
|
|
185
|
+
const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask(deviceApproval(r, c, target, record.folder));
|
|
176
186
|
if (choice === 'deny') {
|
|
177
187
|
await this.result(r, { state: 'denied' });
|
|
178
188
|
return;
|
package/dist/device-helper.js
CHANGED
|
@@ -46,7 +46,8 @@ export async function runDeviceHelper(ctx) {
|
|
|
46
46
|
ctx.io.stdout.write(JSON.stringify(approval) + '\n');
|
|
47
47
|
if (signal.aborted)
|
|
48
48
|
denied();
|
|
49
|
-
})
|
|
49
|
+
}).finally(() => { if (!signal.aborted)
|
|
50
|
+
ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
|
|
50
51
|
};
|
|
51
52
|
const working = new Map();
|
|
52
53
|
const mirrored = new ConversationMirrorState();
|
package/dist/errors.d.ts
CHANGED
|
@@ -45,6 +45,15 @@ export declare class CodedError extends HintedError {
|
|
|
45
45
|
readonly details: Record<string, unknown> | undefined;
|
|
46
46
|
constructor(code: string, message: string, hint?: string, details?: Record<string, unknown>);
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A command that can create an account ran without the agreement to the Terms of Service (0.15.0+): no
|
|
50
|
+
* --accept-terms / SHARDFLUX_ACCEPT_TERMS=1 and no terminal to ask on, or the person answered no. Nothing was sent.
|
|
51
|
+
* Code `terms_not_accepted`, exit 2 (usage); `details` has both links and the flag.
|
|
52
|
+
*/
|
|
53
|
+
export declare class TermsNotAcceptedError extends CodedError {
|
|
54
|
+
name: string;
|
|
55
|
+
constructor(declined?: boolean);
|
|
56
|
+
}
|
|
48
57
|
/** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
|
|
49
58
|
export declare class AuthConfigError extends HintedError {
|
|
50
59
|
name: string;
|
package/dist/errors.js
CHANGED
|
@@ -66,6 +66,19 @@ export class CodedError extends HintedError {
|
|
|
66
66
|
this.details = details;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* A command that can create an account ran without the agreement to the Terms of Service (0.15.0+): no
|
|
71
|
+
* --accept-terms / SHARDFLUX_ACCEPT_TERMS=1 and no terminal to ask on, or the person answered no. Nothing was sent.
|
|
72
|
+
* Code `terms_not_accepted`, exit 2 (usage); `details` has both links and the flag.
|
|
73
|
+
*/
|
|
74
|
+
export class TermsNotAcceptedError extends CodedError {
|
|
75
|
+
name = 'TermsNotAcceptedError';
|
|
76
|
+
constructor(declined = false) {
|
|
77
|
+
super('terms_not_accepted', declined
|
|
78
|
+
? 'Not signed up: creating an account needs your agreement to the Terms of Service and Privacy Policy.'
|
|
79
|
+
: 'Re-run with --accept-terms to agree to the Terms of Service (https://shardflux.dev/terms) and Privacy Policy (https://shardflux.dev/privacy); creating an account needs that agreement.', 'read https://shardflux.dev/terms and https://shardflux.dev/privacy, then re-run with --accept-terms (or set SHARDFLUX_ACCEPT_TERMS=1)', { terms_url: 'https://shardflux.dev/terms', privacy_url: 'https://shardflux.dev/privacy', flag: '--accept-terms' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
69
82
|
/** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
|
|
70
83
|
export class AuthConfigError extends HintedError {
|
|
71
84
|
name = 'AuthConfigError';
|
|
@@ -139,7 +152,7 @@ export class ExecSessionRunningError extends HintedError {
|
|
|
139
152
|
this.session = session;
|
|
140
153
|
}
|
|
141
154
|
}
|
|
142
|
-
const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed']);
|
|
155
|
+
const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed', 'access_denied', 'expired_token']);
|
|
143
156
|
const KEY_PATTERN = /sfk_[A-Za-z0-9]{8,}_[A-Za-z0-9]+/g;
|
|
144
157
|
/** User session tokens (`sfu_` + 43 base64url characters), also cut short. */
|
|
145
158
|
const SESSION_PATTERN = /sfu_[A-Za-z0-9_-]{16,}/g;
|
|
@@ -261,7 +274,7 @@ export function describeError(err) {
|
|
|
261
274
|
export function exitCodeFor(err, interrupted = false) {
|
|
262
275
|
if (interrupted || err instanceof InterruptedError)
|
|
263
276
|
return EXIT.interrupted;
|
|
264
|
-
if (err instanceof UsageError || err instanceof TemplateFileError)
|
|
277
|
+
if (err instanceof UsageError || err instanceof TemplateFileError || err instanceof TermsNotAcceptedError)
|
|
265
278
|
return EXIT.usage;
|
|
266
279
|
if (err instanceof AuthConfigError || err instanceof MfaPendingError)
|
|
267
280
|
return EXIT.auth;
|
package/dist/handlers.js
CHANGED
|
@@ -15,4 +15,5 @@ import { MENUBAR_HANDLERS } from "./menubar.js";
|
|
|
15
15
|
import { UPDATE_HANDLERS } from "./update.js";
|
|
16
16
|
import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
|
|
17
17
|
import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
|
|
18
|
-
|
|
18
|
+
import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
|
|
19
|
+
export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
import type { TerminalConnection } from './agent-terminal.js';
|
|
4
|
+
import type { CellClient } from '@shardflux/sdk';
|
|
5
|
+
/** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
|
|
6
|
+
export declare function prepareInitialCodexProject(cell: CellClient, cwd: string, ptyId: string): Promise<void>;
|
|
7
|
+
export declare function readInitialPrompt(ctx: Ctx, values: Values): Promise<string | undefined>;
|
|
8
|
+
/** Images for a new conversation's first message. They are held in memory and written only into the conversation VM. */
|
|
9
|
+
export interface InitialImage {
|
|
10
|
+
path: string;
|
|
11
|
+
data: Uint8Array;
|
|
12
|
+
type: 'png' | 'jpeg' | 'gif' | 'webp';
|
|
13
|
+
}
|
|
14
|
+
export declare const ATTACHMENT_DIR = "/home/user/.shardflux/attachments";
|
|
15
|
+
export declare const MAX_INITIAL_IMAGES = 5;
|
|
16
|
+
export declare const MAX_INITIAL_IMAGE_BYTES: number;
|
|
17
|
+
/** Standard input may carry several PNG files back to back (the menu bar's in-memory captures). */
|
|
18
|
+
export declare function splitImages(data: Uint8Array): Uint8Array[];
|
|
19
|
+
/** Reads and validates `--image <file|->` before any VM is allocated. */
|
|
20
|
+
export declare function readInitialImages(ctx: Ctx, values: Values, prompt: string | undefined): Promise<InitialImage[]>;
|
|
21
|
+
/** One VM path per line: Claude Code attaches each as an image when the prompt is pasted; Codex opens it with view_image. */
|
|
22
|
+
export declare function promptWithImages(prompt: string, images: readonly InitialImage[]): string;
|
|
23
|
+
/** Writes the images into the conversation VM before the first message refers to them. */
|
|
24
|
+
export declare function uploadInitialImages(cell: Pick<CellClient, 'files'>, images: readonly InitialImage[], signal?: AbortSignal): Promise<void>;
|
|
25
|
+
/** Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay. */
|
|
26
|
+
export declare function submitInitialPrompt(connect: (signal: AbortSignal) => Promise<TerminalConnection>, prompt: string, workspaceKey: string, signal: AbortSignal, app?: 'claude' | 'codex', write?: (bytes: Uint8Array) => Promise<unknown>): Promise<void>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readInput, str, strs } from "./commands.js";
|
|
3
|
+
import { PtyOutputCursor } from "./agent-terminal.js";
|
|
4
|
+
import { CodedError, UsageError } from "./errors.js";
|
|
5
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
6
|
+
import { parse, stringify } from 'smol-toml';
|
|
7
|
+
/** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
|
|
8
|
+
export async function prepareInitialCodexProject(cell, cwd, ptyId) {
|
|
9
|
+
const path = '/home/user/.codex/config.toml';
|
|
10
|
+
let source = '', revision = 'absent';
|
|
11
|
+
try {
|
|
12
|
+
const old = await cell.files.readWithInfo(path);
|
|
13
|
+
source = new TextDecoder().decode(old.data);
|
|
14
|
+
if (!old.revision)
|
|
15
|
+
throw new Error('Codex config omitted its revision.');
|
|
16
|
+
revision = old.revision;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!(error instanceof ShardfluxApiError && error.status === 404))
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const doc = parse(source);
|
|
23
|
+
const projects = doc.projects && typeof doc.projects === 'object' && !Array.isArray(doc.projects) ? doc.projects : {};
|
|
24
|
+
projects[cwd] = { ...(projects[cwd] ?? {}), trust_level: 'trusted' };
|
|
25
|
+
doc.projects = projects;
|
|
26
|
+
// The VM image owns Codex's version. An interactive updater must not intercept the first task.
|
|
27
|
+
doc.check_for_update_on_startup = false;
|
|
28
|
+
await cell.files.mkdir('/home/user/.codex', { parents: true });
|
|
29
|
+
await cell.files.patch({ path, content: stringify(doc), expectedRevision: revision, mode: '0600' });
|
|
30
|
+
// A new conversation's VM can auto-start its empty terminal before the CLI seeds/configures it.
|
|
31
|
+
// Reload that owned, never-prompted terminal so startup reads the chosen worktree's trust setting.
|
|
32
|
+
try {
|
|
33
|
+
const session = await cell.pty.get(ptyId);
|
|
34
|
+
if (session.state === 'running' || session.state === 'starting')
|
|
35
|
+
await cell.pty.close(ptyId);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (!(error instanceof ShardfluxApiError && error.status === 404))
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function readInitialPrompt(ctx, values) {
|
|
43
|
+
const prompt = str(values.prompt), file = str(values['prompt-file']);
|
|
44
|
+
if (prompt !== undefined && file !== undefined)
|
|
45
|
+
throw new UsageError('Use --prompt or --prompt-file, not both.');
|
|
46
|
+
const text = file === undefined ? prompt : new TextDecoder('utf-8', { fatal: true }).decode(await readInput(ctx, file, 262144, '--prompt-file'));
|
|
47
|
+
if (text !== undefined && (!text.trim() || text.includes('\0') || text.includes('\x1b') || Buffer.byteLength(text) > 262144)) {
|
|
48
|
+
throw new UsageError('The initial prompt must contain text, be at most 256 KiB, and contain no NUL or terminal escape characters.');
|
|
49
|
+
}
|
|
50
|
+
if (text !== undefined && values.resume === true)
|
|
51
|
+
throw new UsageError('An initial prompt is for a new conversation; use conversations send for an existing conversation.');
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
export const ATTACHMENT_DIR = '/home/user/.shardflux/attachments';
|
|
55
|
+
export const MAX_INITIAL_IMAGES = 5;
|
|
56
|
+
export const MAX_INITIAL_IMAGE_BYTES = 10 << 20;
|
|
57
|
+
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
58
|
+
function imageType(data) {
|
|
59
|
+
const ascii = (from, to) => Buffer.from(data.subarray(from, to)).toString('latin1');
|
|
60
|
+
if (PNG.every((b, i) => data[i] === b))
|
|
61
|
+
return 'png';
|
|
62
|
+
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
|
|
63
|
+
return 'jpeg';
|
|
64
|
+
if (ascii(0, 6) === 'GIF87a' || ascii(0, 6) === 'GIF89a')
|
|
65
|
+
return 'gif';
|
|
66
|
+
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP')
|
|
67
|
+
return 'webp';
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/** Standard input may carry several PNG files back to back (the menu bar's in-memory captures). */
|
|
71
|
+
export function splitImages(data) {
|
|
72
|
+
if (imageType(data) !== 'png')
|
|
73
|
+
return [data];
|
|
74
|
+
const images = [];
|
|
75
|
+
let start = 0;
|
|
76
|
+
while (start < data.length) {
|
|
77
|
+
if (!PNG.every((b, i) => data[start + i] === b))
|
|
78
|
+
throw new UsageError('--image -: standard input must be one image or PNG images back to back.');
|
|
79
|
+
let at = start + 8, end = 0;
|
|
80
|
+
while (!end) {
|
|
81
|
+
if (at + 12 > data.length)
|
|
82
|
+
throw new UsageError('--image -: a PNG image on standard input is truncated.');
|
|
83
|
+
const length = Buffer.from(data.subarray(at, at + 4)).readUInt32BE(0);
|
|
84
|
+
const type = Buffer.from(data.subarray(at + 4, at + 8)).toString('latin1');
|
|
85
|
+
at += 12 + length;
|
|
86
|
+
if (at > data.length)
|
|
87
|
+
throw new UsageError('--image -: a PNG image on standard input is truncated.');
|
|
88
|
+
if (type === 'IEND')
|
|
89
|
+
end = at;
|
|
90
|
+
}
|
|
91
|
+
images.push(data.subarray(start, end));
|
|
92
|
+
start = end;
|
|
93
|
+
}
|
|
94
|
+
return images;
|
|
95
|
+
}
|
|
96
|
+
/** Reads and validates `--image <file|->` before any VM is allocated. */
|
|
97
|
+
export async function readInitialImages(ctx, values, prompt) {
|
|
98
|
+
const sources = strs(values.image);
|
|
99
|
+
if (!sources.length)
|
|
100
|
+
return [];
|
|
101
|
+
if (prompt === undefined)
|
|
102
|
+
throw new UsageError('--image needs an initial prompt: add --prompt or --prompt-file.');
|
|
103
|
+
if (sources.filter(s => s === '-').length > 1)
|
|
104
|
+
throw new UsageError('Use --image - once; standard input may carry several PNG images back to back.');
|
|
105
|
+
if (sources.includes('-') && str(values['prompt-file']) === '-')
|
|
106
|
+
throw new UsageError('--prompt-file - and --image - both read standard input. Pass one of them as a file.');
|
|
107
|
+
const id = randomUUID();
|
|
108
|
+
const images = [];
|
|
109
|
+
for (const source of sources) {
|
|
110
|
+
const data = await readInput(ctx, source, MAX_INITIAL_IMAGES * MAX_INITIAL_IMAGE_BYTES, '--image');
|
|
111
|
+
for (const part of source === '-' ? splitImages(data) : [data]) {
|
|
112
|
+
const type = imageType(part);
|
|
113
|
+
if (!type)
|
|
114
|
+
throw new UsageError(`--image ${source}: use a PNG, JPEG, GIF or WebP image.`);
|
|
115
|
+
if (part.length > MAX_INITIAL_IMAGE_BYTES)
|
|
116
|
+
throw new UsageError(`--image ${source}: each image must be at most ${MAX_INITIAL_IMAGE_BYTES >> 20} MiB.`);
|
|
117
|
+
if (images.length === MAX_INITIAL_IMAGES)
|
|
118
|
+
throw new UsageError(`Attach at most ${MAX_INITIAL_IMAGES} images.`);
|
|
119
|
+
images.push({ path: `${ATTACHMENT_DIR}/${id}-${images.length + 1}.${type === 'jpeg' ? 'jpg' : type}`, data: part, type });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return images;
|
|
123
|
+
}
|
|
124
|
+
/** One VM path per line: Claude Code attaches each as an image when the prompt is pasted; Codex opens it with view_image. */
|
|
125
|
+
export function promptWithImages(prompt, images) {
|
|
126
|
+
if (!images.length)
|
|
127
|
+
return prompt;
|
|
128
|
+
const text = `${prompt}\n\nAttached ${images.length === 1 ? 'image' : 'images'} (view ${images.length === 1 ? 'it' : 'each one'} before you start):\n${images.map(i => i.path).join('\n')}`;
|
|
129
|
+
if (Buffer.byteLength(text) > 262144)
|
|
130
|
+
throw new UsageError('The initial prompt with its image references must be at most 256 KiB.');
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
/** Writes the images into the conversation VM before the first message refers to them. */
|
|
134
|
+
export async function uploadInitialImages(cell, images, signal) {
|
|
135
|
+
for (const image of images) {
|
|
136
|
+
signal?.throwIfAborted();
|
|
137
|
+
await cell.files.write(image.path, image.data, { mode: '0600', createParents: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay. */
|
|
141
|
+
export async function submitInitialPrompt(connect, prompt, workspaceKey, signal, app = 'claude', write) {
|
|
142
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(20000)]);
|
|
143
|
+
let connection;
|
|
144
|
+
let sent = false, pasted = false;
|
|
145
|
+
try {
|
|
146
|
+
connection = await connect(bounded);
|
|
147
|
+
let tail = '', pasteEnabled = false;
|
|
148
|
+
const cursor = new PtyOutputCursor();
|
|
149
|
+
for await (const message of connection.messages()) {
|
|
150
|
+
if (bounded.aborted)
|
|
151
|
+
throw bounded.reason;
|
|
152
|
+
if (message.type === 'exit')
|
|
153
|
+
throw new Error('The agent exited before accepting a prompt.');
|
|
154
|
+
const bytes = cursor.consume(message);
|
|
155
|
+
if (!bytes)
|
|
156
|
+
continue;
|
|
157
|
+
tail = (tail + bytes.toString('utf8')).slice(-32768);
|
|
158
|
+
pasteEnabled ||= tail.includes('\x1b[?2004h');
|
|
159
|
+
// eslint-disable-next-line no-control-regex -- Strip native terminal ANSI before matching prompt echo.
|
|
160
|
+
const plain = tail.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\s/g, '');
|
|
161
|
+
if (sent && bytes.length)
|
|
162
|
+
return;
|
|
163
|
+
if (pasted) {
|
|
164
|
+
// A long prompt overflows the input box: a 24-row terminal shows only its end, so either end confirms the paste.
|
|
165
|
+
const compact = prompt.replace(/\s/g, '');
|
|
166
|
+
if (!plain.includes(compact.slice(0, 32)) && !plain.includes(compact.slice(-32)) && !/Pasted(?:text|content)/i.test(plain))
|
|
167
|
+
continue;
|
|
168
|
+
const enter = Buffer.from('\r');
|
|
169
|
+
if (write)
|
|
170
|
+
await write(enter);
|
|
171
|
+
else
|
|
172
|
+
connection.input(enter);
|
|
173
|
+
sent = true;
|
|
174
|
+
tail = '';
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const ready = app === 'claude' ? tail.includes('❯') : tail.includes('›');
|
|
178
|
+
if (!ready || !pasteEnabled)
|
|
179
|
+
continue;
|
|
180
|
+
const paste = Buffer.from(`\x1b[200~${prompt}\x1b[201~`);
|
|
181
|
+
if (write)
|
|
182
|
+
await write(paste);
|
|
183
|
+
else
|
|
184
|
+
connection.input(paste);
|
|
185
|
+
pasted = true;
|
|
186
|
+
tail = '';
|
|
187
|
+
}
|
|
188
|
+
throw new Error('The agent terminal closed before accepting a prompt.');
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt ${sent ? 'could not be confirmed' : 'was not submitted'}. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, submitted: sent });
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await connection?.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/legal.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Terms of Service notice and acceptance, one place for every sign-in and sign-up path (the dashboard's sign-up
|
|
3
|
+
* page and the Mac app show the same line).
|
|
4
|
+
*
|
|
5
|
+
* - Signing in to an existing account (a password, the browser) only shows the notice: one line on stderr before it
|
|
6
|
+
* acts, or `terms_url` and `privacy_url` in the `--json` output for an app or agent to show.
|
|
7
|
+
* - A command that can create an account (`shard signup`, `shard auth register`, `shard auth login --codex`) needs the
|
|
8
|
+
* agreement first: `--accept-terms` (or SHARDFLUX_ACCEPT_TERMS=1), or "Continue? [Y/n]" on a terminal. Without
|
|
9
|
+
* either it stops before any network call with `terms_not_accepted` (exit 2), so an agent asks its person and
|
|
10
|
+
* re-runs with the flag. The API records the agreement on the new account (`accept_terms`, `terms_version`).
|
|
11
|
+
*/
|
|
12
|
+
import type { Ctx } from './commands.js';
|
|
13
|
+
import type { Values } from './args.js';
|
|
14
|
+
export declare const TERMS_URL = "https://shardflux.dev/terms";
|
|
15
|
+
export declare const PRIVACY_URL = "https://shardflux.dev/privacy";
|
|
16
|
+
/** The published version of the terms: the effective date on the terms page. */
|
|
17
|
+
export declare const TERMS_VERSION = "2026-09-28";
|
|
18
|
+
export declare const ACCEPT_TERMS_FLAG = "--accept-terms";
|
|
19
|
+
export declare const ACCEPT_TERMS_ENV = "SHARDFLUX_ACCEPT_TERMS";
|
|
20
|
+
/** "By continuing, you agree to our Terms of Service (<url>) and acknowledge our Privacy Policy (<url>)." */
|
|
21
|
+
export declare function legalNotice(): string;
|
|
22
|
+
/** The two links in a command's `--json` output. */
|
|
23
|
+
export declare function legalFields(): {
|
|
24
|
+
terms_url: string;
|
|
25
|
+
privacy_url: string;
|
|
26
|
+
};
|
|
27
|
+
/** The notice before a sign-in acts: a line on stderr (none in `--json`, where the output carries the links). */
|
|
28
|
+
export declare function showLegalNotice(ctx: Ctx): void;
|
|
29
|
+
/**
|
|
30
|
+
* The agreement before a command that can create an account, before any network call. Returns what the API records;
|
|
31
|
+
* throws TermsNotAcceptedError when there is none (no flag and no terminal to ask on, or the person said no).
|
|
32
|
+
*/
|
|
33
|
+
export declare function requireTermsAcceptance(ctx: Ctx, values: Values): Promise<{
|
|
34
|
+
acceptTerms: true;
|
|
35
|
+
termsVersion: string;
|
|
36
|
+
}>;
|
package/dist/legal.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { TermsNotAcceptedError } from "./errors.js";
|
|
2
|
+
export const TERMS_URL = 'https://shardflux.dev/terms';
|
|
3
|
+
export const PRIVACY_URL = 'https://shardflux.dev/privacy';
|
|
4
|
+
/** The published version of the terms: the effective date on the terms page. */
|
|
5
|
+
export const TERMS_VERSION = '2026-09-28';
|
|
6
|
+
export const ACCEPT_TERMS_FLAG = '--accept-terms';
|
|
7
|
+
export const ACCEPT_TERMS_ENV = 'SHARDFLUX_ACCEPT_TERMS';
|
|
8
|
+
/** "By continuing, you agree to our Terms of Service (<url>) and acknowledge our Privacy Policy (<url>)." */
|
|
9
|
+
export function legalNotice() {
|
|
10
|
+
return `By continuing, you agree to our Terms of Service (${TERMS_URL}) and acknowledge our Privacy Policy (${PRIVACY_URL}).`;
|
|
11
|
+
}
|
|
12
|
+
/** The two links in a command's `--json` output. */
|
|
13
|
+
export function legalFields() {
|
|
14
|
+
return { terms_url: TERMS_URL, privacy_url: PRIVACY_URL };
|
|
15
|
+
}
|
|
16
|
+
/** The notice before a sign-in acts: a line on stderr (none in `--json`, where the output carries the links). */
|
|
17
|
+
export function showLegalNotice(ctx) {
|
|
18
|
+
if (!ctx.json)
|
|
19
|
+
ctx.io.stderr.write(`${legalNotice()}\n`);
|
|
20
|
+
}
|
|
21
|
+
function acceptedUpFront(ctx, values) {
|
|
22
|
+
if (values['accept-terms'] === true)
|
|
23
|
+
return true;
|
|
24
|
+
const env = ctx.io.env[ACCEPT_TERMS_ENV]?.trim().toLowerCase();
|
|
25
|
+
return env === '1' || env === 'true' || env === 'yes';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The agreement before a command that can create an account, before any network call. Returns what the API records;
|
|
29
|
+
* throws TermsNotAcceptedError when there is none (no flag and no terminal to ask on, or the person said no).
|
|
30
|
+
*/
|
|
31
|
+
export async function requireTermsAcceptance(ctx, values) {
|
|
32
|
+
const accepted = { acceptTerms: true, termsVersion: TERMS_VERSION };
|
|
33
|
+
if (acceptedUpFront(ctx, values)) {
|
|
34
|
+
showLegalNotice(ctx);
|
|
35
|
+
return accepted;
|
|
36
|
+
}
|
|
37
|
+
if (!ctx.input.interactive || ctx.json)
|
|
38
|
+
throw new TermsNotAcceptedError();
|
|
39
|
+
ctx.io.stderr.write(`${legalNotice()}\n`);
|
|
40
|
+
const answer = (await ctx.input.answer('Continue? [Y/n] '))?.trim().toLowerCase() ?? '';
|
|
41
|
+
if (answer === '' || answer === 'y' || answer === 'yes')
|
|
42
|
+
return accepted;
|
|
43
|
+
throw new TermsNotAcceptedError(true);
|
|
44
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -18,6 +18,7 @@ import { commandCenter } from "./command-center.js";
|
|
|
18
18
|
import { launchConversationProject } from "./conversation-terminal.js";
|
|
19
19
|
import { runAcp } from "./acp.js";
|
|
20
20
|
import { registerInstalledT3 } from "./t3-provider.js";
|
|
21
|
+
import { runAcpLogin } from "./acp-login.js";
|
|
21
22
|
import { runDeviceHelper } from "./device-helper.js";
|
|
22
23
|
import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
|
|
23
24
|
import { CredentialStore, configDir } from "./credentials.js";
|
|
@@ -84,6 +85,9 @@ function hintFor(info, cred, file, command = '') {
|
|
|
84
85
|
return STEP_UP_HINT;
|
|
85
86
|
if (info.code === 'mfa_required')
|
|
86
87
|
return 'finish signing in with the code of your authenticator app: shard auth mfa --code <code>';
|
|
88
|
+
// The browser sign-in (0.15.0+): refused in the browser, or not approved within its 10 minutes.
|
|
89
|
+
if (info.code === 'access_denied' || info.code === 'expired_token')
|
|
90
|
+
return 'start the sign-in again: shard auth login --browser';
|
|
87
91
|
if (info.code === 'email_unverified')
|
|
88
92
|
return `verify your email first: shard auth verify-email '<link from the email>' (a new link: shard auth resend-verification)`;
|
|
89
93
|
if (info.code === 'conflict' && reasonOf(info) === 'subscription_exists')
|
|
@@ -316,7 +320,7 @@ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
|
|
|
316
320
|
*/
|
|
317
321
|
// allowance_exhausted (0.5.1): a used-up allowance, a reached spend cap or a paused overage is a billing state whose hint
|
|
318
322
|
// names the next step (upgrade, overage, the cap, the payment method), not a problem to report.
|
|
319
|
-
const NO_NUDGE_CODES = new Set(['usage', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
|
|
323
|
+
const NO_NUDGE_CODES = new Set(['usage', 'terms_not_accepted', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
|
|
320
324
|
const NO_NUDGE_REASONS = new Set([
|
|
321
325
|
'revision_mismatch',
|
|
322
326
|
'tree_revision_mismatch',
|
|
@@ -644,7 +648,7 @@ export async function run(argv, io, opts = {}) {
|
|
|
644
648
|
catch (err) {
|
|
645
649
|
return report(err);
|
|
646
650
|
}
|
|
647
|
-
const handler = parsed.command.path[0] === 'acp' ? (ctx) => runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
|
|
651
|
+
const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
|
|
648
652
|
if (!handler)
|
|
649
653
|
return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
|
|
650
654
|
if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
|
package/dist/menubar.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { Handler } from './commands.js';
|
|
2
2
|
export type NativeRunner = (tool: string, args: string[]) => Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
|
|
5
|
+
* `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
|
|
6
|
+
* whether or not a built Shardflux.app is in the package.
|
|
7
|
+
*/
|
|
8
|
+
export declare const nativeTools: {
|
|
9
|
+
run: (tool: string, args: string[], signal?: AbortSignal) => Promise<string>;
|
|
10
|
+
};
|
|
3
11
|
export interface MenubarStatus {
|
|
4
12
|
installed: boolean;
|
|
5
13
|
version: string | null;
|
package/dist/menubar.js
CHANGED
|
@@ -8,6 +8,14 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
8
8
|
import { UsageError } from "./errors.js";
|
|
9
9
|
import { json } from "./format.js";
|
|
10
10
|
const execute = promisify(execFile);
|
|
11
|
+
/**
|
|
12
|
+
* Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
|
|
13
|
+
* `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
|
|
14
|
+
* whether or not a built Shardflux.app is in the package.
|
|
15
|
+
*/
|
|
16
|
+
export const nativeTools = {
|
|
17
|
+
run: async (tool, args, signal) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal })).stdout,
|
|
18
|
+
};
|
|
11
19
|
const BUNDLE_ID = 'dev.shardflux.menubar';
|
|
12
20
|
export function compareBundleVersions(left, right) {
|
|
13
21
|
if (![left, right].every(v => /^\d+(\.\d+){0,3}$/.test(v)))
|
|
@@ -58,7 +66,7 @@ export class MenubarManager {
|
|
|
58
66
|
constructor(options = {}) {
|
|
59
67
|
this.app = join(options.home ?? homedir(), 'Applications', 'Shardflux.app');
|
|
60
68
|
this.#platform = options.platform ?? process.platform;
|
|
61
|
-
this.#run = options.run ?? (
|
|
69
|
+
this.#run = options.run ?? ((tool, args) => nativeTools.run(tool, args, options.signal));
|
|
62
70
|
this.#packageApp = options.packageApp ?? (() => {
|
|
63
71
|
try {
|
|
64
72
|
return join(dirname(createRequire(import.meta.url).resolve('@shardflux/menubar-darwin/package.json')), 'Shardflux.app');
|