@shardflux/cli 0.24.0 → 0.25.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 +148 -0
- package/README.md +263 -1
- package/dist/account-args.js +5 -0
- package/dist/account-pool.js +5 -1
- package/dist/acp-journal.js +23 -1
- package/dist/acp-permission-elsewhere.d.ts +38 -0
- package/dist/acp-permission-elsewhere.js +85 -0
- package/dist/acp-rpc.d.ts +2 -1
- package/dist/acp-rpc.js +11 -2
- package/dist/acp.js +95 -22
- package/dist/agent-credentials.d.ts +3 -0
- package/dist/agent-credentials.js +7 -0
- package/dist/args.d.ts +1 -1
- package/dist/args.js +172 -9
- package/dist/board-args.d.ts +6 -0
- package/dist/board-args.js +172 -0
- package/dist/board-commands.d.ts +62 -0
- package/dist/board-commands.js +412 -0
- package/dist/codex-cloud.d.ts +30 -3
- package/dist/codex-cloud.js +86 -12
- package/dist/conversation-commands.d.ts +30 -0
- package/dist/conversation-commands.js +328 -9
- package/dist/conversation-transcript.d.ts +44 -0
- package/dist/conversation-transcript.js +206 -0
- package/dist/conversations.d.ts +7 -3
- package/dist/conversations.js +20 -10
- package/dist/device-approvals.js +4 -3
- package/dist/device-bridge.d.ts +42 -1
- package/dist/device-bridge.js +223 -12
- package/dist/device-folders.d.ts +66 -0
- package/dist/device-folders.js +236 -0
- package/dist/device-helper.d.ts +6 -0
- package/dist/device-helper.js +98 -14
- package/dist/drive-commands.d.ts +25 -0
- package/dist/drive-commands.js +201 -0
- package/dist/errors.js +2 -1
- package/dist/format.js +6 -2
- package/dist/handlers.js +7 -1
- package/dist/land.d.ts +116 -0
- package/dist/land.js +238 -0
- package/dist/main.js +10 -1
- package/dist/move-files.d.ts +39 -0
- package/dist/move-files.js +167 -0
- package/dist/move.js +12 -11
- package/dist/onboarding.d.ts +10 -6
- package/dist/onboarding.js +24 -6
- package/dist/project-git.d.ts +7 -1
- package/dist/project-git.js +16 -3
- package/dist/project-preparation.d.ts +7 -0
- package/dist/project-preparation.js +14 -0
- package/dist/project-template-setup.js +32 -11
- package/dist/router-commands.d.ts +33 -0
- package/dist/router-commands.js +179 -0
- package/dist/task-land-hub.py +150 -0
- package/dist/transcribe-commands.d.ts +7 -0
- package/dist/transcribe-commands.js +80 -0
- package/dist/worktree-twin-engine.d.ts +8 -0
- package/dist/worktree-twin-engine.js +28 -22
- package/package.json +2 -2
package/dist/onboarding.js
CHANGED
|
@@ -7,9 +7,9 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
7
7
|
import { describeError } from "./errors.js";
|
|
8
8
|
import { t3PhoneLink } from "./t3-phone.js";
|
|
9
9
|
import { t3NeedsProject, t3SignInProject } from "./account-scope.js";
|
|
10
|
-
import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
|
|
10
|
+
import { credentialFingerprint, readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
|
|
11
11
|
import { getConversation } from "./conversations.js";
|
|
12
|
-
import { codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
|
|
12
|
+
import { CODEX_SIGN_IN_LINE, codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
|
|
13
13
|
import { applicationDirs } from "./agent-targets.js";
|
|
14
14
|
import { configureT3Provider } from "./t3-provider.js";
|
|
15
15
|
import { t3Install } from "./t3-mode.js";
|
|
@@ -198,8 +198,11 @@ export function claudeConnection(row) {
|
|
|
198
198
|
return { state: 'action', fact: 'Claude no longer accepts this sign-in.', action: { ...signIn, title: 'Sign in to Claude again' } };
|
|
199
199
|
return { state: 'verified', fact: 'Signed in. Works in every conversation, on web and phone.' };
|
|
200
200
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected.
|
|
203
|
+
* `renewed`: this computer has a Codex login other than the one in the cloud (a new sign-in not copied yet).
|
|
204
|
+
*/
|
|
205
|
+
export function codexConnection(local, synced, connector, health, renewed = false) {
|
|
203
206
|
const enable = { title: 'Connect Codex', command: shard(['agents', 'codex', 'enable']) };
|
|
204
207
|
if (!synced) {
|
|
205
208
|
return local ? { state: 'action', fact: 'Available on web and phone once connected.', action: enable }
|
|
@@ -215,6 +218,11 @@ export function codexConnection(local, synced, connector, health) {
|
|
|
215
218
|
case 'failed': return { state: 'action', fact: 'Codex’s remote device didn’t start.', action: retry };
|
|
216
219
|
case 'stuck': return { state: 'action', fact: 'Codex’s remote device is taking too long to start.', action: retry };
|
|
217
220
|
case 'attention': return { state: 'action', fact: connector?.last_error ? `Codex needs attention: ${connector.last_error}` : 'Codex needs attention.', action: retry };
|
|
221
|
+
// Codex Cloud refused the copied ChatGPT login (0.25.0+): sign in to Codex again here, then copy that sign-in, which
|
|
222
|
+
// starts the device again by itself. Never Retry: a restart cannot fix a sign-in.
|
|
223
|
+
case 'sign_in': return renewed
|
|
224
|
+
? { state: 'action', fact: 'Your new Codex sign-in is ready to copy to the cloud.', action: { title: 'Copy sign-in', command: shard(['agents', 'credentials', 'sync']) } }
|
|
225
|
+
: { state: 'action', fact: CODEX_SIGN_IN_LINE, action: { title: 'Sign in to Codex again', command: ['codex', 'login'] } };
|
|
218
226
|
}
|
|
219
227
|
}
|
|
220
228
|
/**
|
|
@@ -237,7 +245,10 @@ export async function onboardingReport(ctx, values, platform = process.platform)
|
|
|
237
245
|
let connector = null;
|
|
238
246
|
let health = 'missing';
|
|
239
247
|
const home = ctx.io.env.HOME ?? ctx.io.env.USERPROFILE;
|
|
240
|
-
const
|
|
248
|
+
const localLogin = home ? (await readAgentCredentials({ home, redact: value => ctx.addSecret(value) })).find(v => v.app === 'codex') : undefined;
|
|
249
|
+
const localCodex = localLogin !== undefined;
|
|
250
|
+
/** Whether this computer's Codex login differs from the copy in the cloud. */
|
|
251
|
+
const renewed = (rows) => localLogin !== undefined && rows.find(row => row.app === 'codex')?.fingerprint !== credentialFingerprint(localLogin.files);
|
|
241
252
|
try {
|
|
242
253
|
ctx.apiKey();
|
|
243
254
|
const principal = await ctx.cloud().me();
|
|
@@ -284,6 +295,13 @@ export async function onboardingReport(ctx, values, platform = process.platform)
|
|
|
284
295
|
read = { connector: await ctx.cloud().codex.enable({ signal: ctx.signal }), workspaceState: null };
|
|
285
296
|
health = codexHealth(read.connector, null);
|
|
286
297
|
}
|
|
298
|
+
else if (connect && synced && health === 'sign_in' && renewed(credentials) && home) {
|
|
299
|
+
// A new Codex sign-in on this computer: copy it, and the device starts again with it (never a restart).
|
|
300
|
+
await syncAgentCredentials(ctx.cloud(), { home, redact: value => ctx.addSecret(value) });
|
|
301
|
+
credentials = await listCredentials();
|
|
302
|
+
read = { connector: await ctx.cloud().codex.enable({ signal: ctx.signal }), workspaceState: null };
|
|
303
|
+
health = codexHealth(read.connector, null);
|
|
304
|
+
}
|
|
287
305
|
else if (connect && synced && values['retry-codex'] === true && (health === 'failed' || health === 'stuck' || health === 'attention')) {
|
|
288
306
|
read = { connector: await restartCodexConnector(ctx), workspaceState: null };
|
|
289
307
|
health = codexHealth(read.connector, null);
|
|
@@ -304,7 +322,7 @@ export async function onboardingReport(ctx, values, platform = process.platform)
|
|
|
304
322
|
...(!connected ? signIn : codexError instanceof ShardfluxApiError && codexError.reason === 'codex_chatgpt_login_required'
|
|
305
323
|
? { state: 'action', fact: codexError.message, action: { title: 'Sign in with ChatGPT', command: ['codex', 'login'] } }
|
|
306
324
|
: (credentialError ?? codexError) !== undefined ? failure(credentialError ?? codexError)
|
|
307
|
-
: codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health)) };
|
|
325
|
+
: codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health, renewed(credentials))) };
|
|
308
326
|
const t3 = await t3Connection(ctx, connected || ctx.sessionSource() !== null, undefined, undefined, values['t3-connect'] === true, values);
|
|
309
327
|
const connections = [account, claude, codex, ...(t3 ? [t3] : [])];
|
|
310
328
|
// The cloud machine (0.20.2+), the same row `shard machine setup --status` reports: whether what is installed carries
|
package/dist/project-git.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { Ctx } from './commands.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Prepare only the selected project root; never a nested repository. A folder without Git becomes a repository whose
|
|
4
|
+
* first commit is its files (.gitignore respected; large untracked media stay untracked, as a twin snapshot leaves
|
|
5
|
+
* them): a conversation then starts from the same commit as the folder and its work fast-forwards back, as in a
|
|
6
|
+
* repository the person made (0.25.0+; before, an empty first commit left every file to a snapshot, and landings
|
|
7
|
+
* conflicted). A repository of the person's own keeps its staged and untracked files as they are.
|
|
8
|
+
*/
|
|
3
9
|
export declare function ensureProjectGit(ctx: Ctx, folder: string): Promise<void>;
|
package/dist/project-git.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { existsSync, realpathSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { gitRunner } from "./agent-git.js";
|
|
4
5
|
import { GitError } from "./errors.js";
|
|
5
|
-
|
|
6
|
+
import { largeUntrackedMedia, outsideSpecs } from "./large-media.js";
|
|
7
|
+
/**
|
|
8
|
+
* Prepare only the selected project root; never a nested repository. A folder without Git becomes a repository whose
|
|
9
|
+
* first commit is its files (.gitignore respected; large untracked media stay untracked, as a twin snapshot leaves
|
|
10
|
+
* them): a conversation then starts from the same commit as the folder and its work fast-forwards back, as in a
|
|
11
|
+
* repository the person made (0.25.0+; before, an empty first commit left every file to a snapshot, and landings
|
|
12
|
+
* conflicted). A repository of the person's own keeps its staged and untracked files as they are.
|
|
13
|
+
*/
|
|
6
14
|
export async function ensureProjectGit(ctx, folder) {
|
|
7
15
|
const git = gitRunner(folder, { ...process.env, ...ctx.io.env, LC_ALL: 'C' }, ctx.signal, false);
|
|
8
16
|
let initialized = false;
|
|
@@ -37,7 +45,12 @@ export async function ensureProjectGit(ctx, folder) {
|
|
|
37
45
|
throw error;
|
|
38
46
|
}
|
|
39
47
|
// --only with no paths makes an empty commit even when the user already has staged files.
|
|
40
|
-
|
|
48
|
+
let commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '--only', '-m', 'Initial commit'];
|
|
49
|
+
if (initialized) {
|
|
50
|
+
const media = await largeUntrackedMedia(folder, ctx.store.dir, `init-${createHash('sha256').update(folder).digest('hex').slice(0, 16)}`, ctx.signal);
|
|
51
|
+
await git(['add', '-A', '--', '.', ...outsideSpecs(media)]);
|
|
52
|
+
commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '-m', 'Initial commit'];
|
|
53
|
+
}
|
|
41
54
|
try {
|
|
42
55
|
await git(commit);
|
|
43
56
|
}
|
|
@@ -47,6 +60,6 @@ export async function ensureProjectGit(ctx, folder) {
|
|
|
47
60
|
await git(['-c', 'user.name=Shardflux', '-c', 'user.email=noreply@shardflux.dev', ...commit]);
|
|
48
61
|
}
|
|
49
62
|
ctx.io.stderr.write(initialized
|
|
50
|
-
? `Initialized a git repository in ${folder} (shard tracks your project with git).\n`
|
|
63
|
+
? `Initialized a git repository in ${folder} with its files as the first commit (shard tracks your project with git).\n`
|
|
51
64
|
: `Created an initial empty commit in ${folder}.\n`);
|
|
52
65
|
}
|
|
@@ -38,6 +38,13 @@ export interface SetupStatus {
|
|
|
38
38
|
}[];
|
|
39
39
|
}
|
|
40
40
|
export declare function setupFailure(status: SetupStatus): CodedError;
|
|
41
|
+
/**
|
|
42
|
+
* A setup that failed only because the project has no build or test command (the guest's `.:checks` step, unresolved
|
|
43
|
+
* at detection) has nothing left to prepare: its files are in the cache and every other step succeeded. That is a
|
|
44
|
+
* clean ready, never an error that waits for an edit (0.25.0+; before, a plain folder from `projects link` stayed
|
|
45
|
+
* flagged).
|
|
46
|
+
*/
|
|
47
|
+
export declare function nothingToCheck(status: SetupStatus): boolean;
|
|
41
48
|
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
42
49
|
export declare const PREPARE_AGAIN_BYTES: number;
|
|
43
50
|
/** Quiet this long after the last edit before preparing again. */
|
|
@@ -40,6 +40,18 @@ export function setupFailure(status) {
|
|
|
40
40
|
const fix = `${first}${step ? `, or set run for ${step.name} under steps in .shardflux/setup.yaml` : ''}; the project is prepared again after the change`;
|
|
41
41
|
return new SetupFailed('project_setup_failed', message, fix, step ? { step: step.name, ...(step.error?.code ? { error_code: step.error.code } : {}) } : undefined);
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* A setup that failed only because the project has no build or test command (the guest's `.:checks` step, unresolved
|
|
45
|
+
* at detection) has nothing left to prepare: its files are in the cache and every other step succeeded. That is a
|
|
46
|
+
* clean ready, never an error that waits for an edit (0.25.0+; before, a plain folder from `projects link` stayed
|
|
47
|
+
* flagged).
|
|
48
|
+
*/
|
|
49
|
+
export function nothingToCheck(status) {
|
|
50
|
+
const failed = status.tasks?.filter(t => t.state === 'failed') ?? [];
|
|
51
|
+
return status.state === 'failed' && failed.length > 0
|
|
52
|
+
&& failed.every(t => t.error?.code === 'setup_detection_unresolved' && /(^|:)checks$/.test(t.name))
|
|
53
|
+
&& (status.tasks ?? []).every(t => t.state === 'failed' ? failed.includes(t) : ['succeeded', 'skipped'].includes(t.state));
|
|
54
|
+
}
|
|
43
55
|
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
44
56
|
export const PREPARE_AGAIN_BYTES = 8 << 20;
|
|
45
57
|
/** Quiet this long after the last edit before preparing again. */
|
|
@@ -158,6 +170,8 @@ export async function prepareSelectedProject(ctx, selected) {
|
|
|
158
170
|
if (status.exitCode !== 0)
|
|
159
171
|
throw new Error(`Project setup status failed: ${status.stderr.trim()}`);
|
|
160
172
|
const state = JSON.parse(status.stdout);
|
|
173
|
+
if (nothingToCheck(state))
|
|
174
|
+
break;
|
|
161
175
|
if (state.state === 'failed')
|
|
162
176
|
throw setupFailure(state);
|
|
163
177
|
if (Date.now() > deadline)
|
|
@@ -23,20 +23,41 @@ export function dependencyHash(folder) {
|
|
|
23
23
|
throw error;
|
|
24
24
|
}
|
|
25
25
|
const files = [];
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
const wanted = (path, name, inside) => name !== '.env' && !name.startsWith('.env.') && (inside || path.startsWith('.github/workflows/') || dependencyName(name));
|
|
27
|
+
if (selected) {
|
|
28
|
+
// Git's list is the whole input: walking the folder visited every ignored and untracked tree too (a folder of
|
|
29
|
+
// agent worktrees held 1.2 million entries and cost 29 s of every start).
|
|
30
|
+
for (const path of selected) {
|
|
31
|
+
const parts = path.split('/'), name = parts.at(-1);
|
|
32
|
+
if (!name || parts.slice(0, -1).some(dir => skippedDirs.has(dir)) || !wanted(path, name, parts.includes('.devcontainer')))
|
|
33
|
+
continue;
|
|
34
|
+
let st;
|
|
35
|
+
try {
|
|
36
|
+
st = lstatSync(join(folder, path));
|
|
34
37
|
}
|
|
35
|
-
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (st.isFile() || st.isSymbolicLink())
|
|
36
42
|
files.push(path);
|
|
37
43
|
}
|
|
38
|
-
}
|
|
39
|
-
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const walk = (relative, devcontainer) => {
|
|
47
|
+
for (const name of readdirSync(join(folder, relative)).sort()) {
|
|
48
|
+
const path = relative ? `${relative}/${name}` : name;
|
|
49
|
+
const st = lstatSync(join(folder, path));
|
|
50
|
+
const inside = devcontainer || name === '.devcontainer';
|
|
51
|
+
if (st.isDirectory()) {
|
|
52
|
+
if (!skippedDirs.has(name))
|
|
53
|
+
walk(path, inside);
|
|
54
|
+
}
|
|
55
|
+
else if ((st.isFile() || st.isSymbolicLink()) && wanted(path, name, inside))
|
|
56
|
+
files.push(path);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk('', false);
|
|
60
|
+
}
|
|
40
61
|
const hash = createHash('sha256').update('shardflux-deps-v2\0');
|
|
41
62
|
for (const path of files.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) {
|
|
42
63
|
const st = lstatSync(join(folder, path));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RouterResult, Shardflux, ShardfluxAccount } from '@shardflux/sdk';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
import type { Ctx, Handler } from './commands.js';
|
|
4
|
+
/** The API's bound on one input or question (characters). */
|
|
5
|
+
export declare const BOARD_TEXT_MAX = 4000;
|
|
6
|
+
interface BoardClient {
|
|
7
|
+
client: Shardflux | ShardfluxAccount;
|
|
8
|
+
/** The signed-in session: the organization's whole board. */
|
|
9
|
+
session: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
|
|
12
|
+
export declare function boardClient(ctx: Ctx): BoardClient;
|
|
13
|
+
interface Scope {
|
|
14
|
+
organizationId?: string;
|
|
15
|
+
projectId?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
|
|
19
|
+
* only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
|
|
20
|
+
* theirs and names the fix (resolveOrg), and an organization that resolves is used.
|
|
21
|
+
*/
|
|
22
|
+
export declare function inScope<T>(ctx: Ctx, values: Values, call: (client: Shardflux | ShardfluxAccount, scope: Scope) => Promise<T>): Promise<T>;
|
|
23
|
+
/**
|
|
24
|
+
* The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
|
|
25
|
+
* their titles).
|
|
26
|
+
*
|
|
27
|
+
* 2 tasks in site
|
|
28
|
+
* 1. Fix the landing page hero image
|
|
29
|
+
* fix the hero image on the landing page
|
|
30
|
+
*/
|
|
31
|
+
export declare function routeText(r: RouterResult, names: ReadonlyMap<string, string>): string;
|
|
32
|
+
export declare const ROUTER_HANDLERS: Record<string, Handler>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shard route` and `shard ask` (0.25.0+): the board's input bar from a terminal, through @shardflux/sdk's
|
|
3
|
+
* `router.route()` and `questions.stream()` / `questions.ask()`. The signed-in session reads every project of an
|
|
4
|
+
* organization; SHARDFLUX_API_KEY in the environment (or a saved key without a session) reads that key's project.
|
|
5
|
+
*/
|
|
6
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
7
|
+
import { UUID } from "./args.js";
|
|
8
|
+
import { findOrg, resolveOrg, resolveProject } from "./account-scope.js";
|
|
9
|
+
import { out, str } from "./commands.js";
|
|
10
|
+
import { EXIT, UsageError } from "./errors.js";
|
|
11
|
+
/** The API's bound on one input or question (characters). */
|
|
12
|
+
export const BOARD_TEXT_MAX = 4000;
|
|
13
|
+
const TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
14
|
+
/** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
|
|
15
|
+
export function boardClient(ctx) {
|
|
16
|
+
if (!ctx.io.env.SHARDFLUX_API_KEY?.trim() && ctx.sessionSource() !== null)
|
|
17
|
+
return { client: ctx.account(), session: true };
|
|
18
|
+
return { client: ctx.cloud(), session: false };
|
|
19
|
+
}
|
|
20
|
+
/** --org / --project as ids (slugs resolved with the session); with neither, the saved default organization. */
|
|
21
|
+
async function scopeOf(ctx, values, b) {
|
|
22
|
+
const org = str(values.org), project = str(values.project);
|
|
23
|
+
if (!b.session) {
|
|
24
|
+
for (const [flag, v] of [['--org', org], ['--project', project]]) {
|
|
25
|
+
if (v !== undefined && !UUID.test(v))
|
|
26
|
+
throw new UsageError(`${flag} must be an id (UUID) with a project API key`, 'or sign in (shard auth login) to name it by slug');
|
|
27
|
+
}
|
|
28
|
+
return { scope: { ...(org === undefined ? {} : { organizationId: org }), ...(project === undefined ? {} : { projectId: project }) }, defaulted: false };
|
|
29
|
+
}
|
|
30
|
+
const account = b.client;
|
|
31
|
+
const scope = {};
|
|
32
|
+
if (org !== undefined)
|
|
33
|
+
scope.organizationId = UUID.test(org) ? org : (await findOrg(account, org)).id;
|
|
34
|
+
if (project !== undefined)
|
|
35
|
+
scope.projectId = UUID.test(project) ? project : (await resolveProject(ctx, account, values)).id;
|
|
36
|
+
if (org !== undefined || project !== undefined)
|
|
37
|
+
return { scope, defaulted: false };
|
|
38
|
+
const saved = ctx.store.profile(ctx.apiUrl).organization_id;
|
|
39
|
+
return { scope: saved ? { organizationId: saved } : {}, defaulted: true };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
|
|
43
|
+
* only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
|
|
44
|
+
* theirs and names the fix (resolveOrg), and an organization that resolves is used.
|
|
45
|
+
*/
|
|
46
|
+
export async function inScope(ctx, values, call) {
|
|
47
|
+
const b = boardClient(ctx);
|
|
48
|
+
const { scope, defaulted } = await scopeOf(ctx, values, b);
|
|
49
|
+
try {
|
|
50
|
+
return await call(b.client, scope);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (!b.session || !defaulted || !(err instanceof ShardfluxApiError))
|
|
54
|
+
throw err;
|
|
55
|
+
const several = err.status === 422 && scope.organizationId === undefined && /organization/i.test(err.message);
|
|
56
|
+
const gone = err.status === 404 && scope.organizationId !== undefined;
|
|
57
|
+
if (!several && !gone)
|
|
58
|
+
throw err;
|
|
59
|
+
const org = await resolveOrg(ctx, b.client, {});
|
|
60
|
+
if (org.id === scope.organizationId)
|
|
61
|
+
throw err;
|
|
62
|
+
return call(b.client, { organizationId: org.id });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function textOf(words, what, command) {
|
|
66
|
+
const text = words.join(' ').trim();
|
|
67
|
+
if (!text)
|
|
68
|
+
throw new UsageError(`missing <${what}> for "shard ${command}"`);
|
|
69
|
+
if (text.length > BOARD_TEXT_MAX)
|
|
70
|
+
throw new UsageError(`the ${what} is ${text.length} characters; the limit is ${BOARD_TEXT_MAX}`);
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
function taskOf(values) {
|
|
74
|
+
const task = str(values.task);
|
|
75
|
+
if (task !== undefined && !TASK_ID.test(task))
|
|
76
|
+
throw new UsageError('--task must be a task id (task_…)');
|
|
77
|
+
return task;
|
|
78
|
+
}
|
|
79
|
+
/** Project names for the readable form (GET /v1/projects/{id}); an id that cannot be read prints as itself. */
|
|
80
|
+
async function projectNames(client, parts, signal) {
|
|
81
|
+
const ids = [...new Set(parts.filter((p) => !p.home && p.project_id).map((p) => p.project_id))];
|
|
82
|
+
const names = await Promise.all(ids.map((id) => client.request('GET', `/v1/projects/${encodeURIComponent(id)}`, { signal }).then((p) => p.name, () => id)));
|
|
83
|
+
return new Map(ids.map((id, i) => [id, names[i]]));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
|
|
87
|
+
* their titles).
|
|
88
|
+
*
|
|
89
|
+
* 2 tasks in site
|
|
90
|
+
* 1. Fix the landing page hero image
|
|
91
|
+
* fix the hero image on the landing page
|
|
92
|
+
*/
|
|
93
|
+
export function routeText(r, names) {
|
|
94
|
+
const where = (p) => (p.home ? 'on your home' : p.project_id ? `in ${names.get(p.project_id) ?? p.project_id}` : '');
|
|
95
|
+
const parts = r.parts;
|
|
96
|
+
const same = parts.every((p) => where(p) === where(parts[0]));
|
|
97
|
+
let head = r.kind === 'task' && parts.length > 1 ? `${parts.length} tasks` : r.kind;
|
|
98
|
+
if (r.task_ref)
|
|
99
|
+
head += r.kind === 'steer' ? ` ${r.task_ref}` : ` about ${r.task_ref}`;
|
|
100
|
+
if (same && where(parts[0]))
|
|
101
|
+
head += ` ${where(parts[0])}`;
|
|
102
|
+
const notes = [r.forced === 'question' ? 'asked as a question' : '', r.uncertain ? 'project uncertain' : ''].filter(Boolean);
|
|
103
|
+
if (notes.length)
|
|
104
|
+
head += ` (${notes.join(', ')})`;
|
|
105
|
+
const lines = [head];
|
|
106
|
+
if (parts.length === 1) {
|
|
107
|
+
const p = parts[0];
|
|
108
|
+
if (p.title)
|
|
109
|
+
lines.push(` ${p.title}`);
|
|
110
|
+
lines.push(` ${p.text}${same ? '' : ` (${where(p)})`}`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
parts.forEach((p, i) => {
|
|
114
|
+
lines.push(` ${i + 1}. ${p.title ?? p.text}${same ? '' : ` (${where(p)})`}`);
|
|
115
|
+
if (p.title)
|
|
116
|
+
lines.push(` ${p.text}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return `${lines.join('\n')}\n`;
|
|
120
|
+
}
|
|
121
|
+
const interrupted = (ctx, err) => {
|
|
122
|
+
if (ctx.signal.aborted)
|
|
123
|
+
return EXIT.interrupted;
|
|
124
|
+
throw err;
|
|
125
|
+
};
|
|
126
|
+
export const ROUTER_HANDLERS = {
|
|
127
|
+
async route(ctx, values, words) {
|
|
128
|
+
const text = textOf(words, 'text', 'route');
|
|
129
|
+
const selectedTaskId = taskOf(values);
|
|
130
|
+
try {
|
|
131
|
+
const { result, client } = await inScope(ctx, values, async (client, scope) => ({
|
|
132
|
+
client,
|
|
133
|
+
result: await client.router.route({ text, ...(selectedTaskId === undefined ? {} : { selectedTaskId }), ...scope, signal: ctx.signal }),
|
|
134
|
+
}));
|
|
135
|
+
if (ctx.json) {
|
|
136
|
+
out(ctx, '', result);
|
|
137
|
+
return EXIT.ok;
|
|
138
|
+
}
|
|
139
|
+
ctx.io.stdout.write(routeText(result, await projectNames(client, result.parts, ctx.signal)));
|
|
140
|
+
return EXIT.ok;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return interrupted(ctx, err);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
async ask(ctx, values, words) {
|
|
147
|
+
const text = textOf(words, 'question', 'ask');
|
|
148
|
+
const taskId = taskOf(values);
|
|
149
|
+
const params = { text, ...(taskId === undefined ? {} : { taskId }), signal: ctx.signal };
|
|
150
|
+
try {
|
|
151
|
+
if (ctx.json) {
|
|
152
|
+
out(ctx, '', await inScope(ctx, values, (client, scope) => client.questions.ask({ ...params, ...scope })));
|
|
153
|
+
return EXIT.ok;
|
|
154
|
+
}
|
|
155
|
+
// Standard output carries the answer as it is written; the place in line (while it waits) goes to stderr.
|
|
156
|
+
await inScope(ctx, values, async (client, scope) => {
|
|
157
|
+
let written = '';
|
|
158
|
+
for await (const e of client.questions.stream({ ...params, ...scope })) {
|
|
159
|
+
if (e.type === 'queued')
|
|
160
|
+
ctx.io.stderr.write(`Queued: position ${e.position}, about ${Math.max(1, Math.round(e.expectedWaitMs / 1000))} s\n`);
|
|
161
|
+
else if (e.type === 'delta') {
|
|
162
|
+
ctx.io.stdout.write(e.text);
|
|
163
|
+
written += e.text;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
if (!written)
|
|
167
|
+
ctx.io.stdout.write((written = e.answer.text));
|
|
168
|
+
if (!written.endsWith('\n'))
|
|
169
|
+
ctx.io.stdout.write('\n');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return EXIT.ok;
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return interrupted(ctx, err);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Shardflux result landing on the project hub: runs in a VM of the project (shipped and started by the shard CLI,
|
|
2
|
+
land.ts), next to the hub on shared storage.
|
|
3
|
+
|
|
4
|
+
Input: one JSON object on stdin. Output: one JSON object on stdout (exit 0; "state" says what happened).
|
|
5
|
+
|
|
6
|
+
hub the project hub (a bare repository)
|
|
7
|
+
result the result branch (shard/<task id>): the master merged its workers' branches into it
|
|
8
|
+
sources worker branches; when the result branch is missing they are merged into it here, each only when the merge
|
|
9
|
+
is clean (a conflict is the master's to resolve: state "conflict", stage "integrate")
|
|
10
|
+
default the branch to land in (default: the hub's HEAD branch, else main or master)
|
|
11
|
+
title for the merge commit's message
|
|
12
|
+
|
|
13
|
+
The result commit T lands in the default branch D: a fast-forward when D is an ancestor of T, nothing when T is already
|
|
14
|
+
in D, else a merge commit when the merge is clean (a conflict: state "conflict", stage "default"; nothing changes). The
|
|
15
|
+
hub's branches move only by `git push` from a temporary repository, never by force, so a branch someone else moved
|
|
16
|
+
meanwhile is refused (stage "raced") instead of overwritten. Before answering "landed" it checks on the hub itself that
|
|
17
|
+
T is contained in D, and which sources are contained in T.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
IDENTITY = {'GIT_AUTHOR_NAME': 'Shardflux', 'GIT_AUTHOR_EMAIL': 'landing@shardflux.dev',
|
|
27
|
+
'GIT_COMMITTER_NAME': 'Shardflux', 'GIT_COMMITTER_EMAIL': 'landing@shardflux.dev'}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Fail(Exception):
|
|
31
|
+
def __init__(self, reason, message):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.reason = reason
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def git(cwd, *args, ok=(0,), data=None):
|
|
37
|
+
env = dict(os.environ, GIT_TERMINAL_PROMPT='0', LC_ALL='C', **IDENTITY)
|
|
38
|
+
p = subprocess.run(['git', '-c', 'core.hooksPath=/dev/null', *args], cwd=cwd, env=env, input=data,
|
|
39
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
40
|
+
if p.returncode not in ok:
|
|
41
|
+
raise Fail('git', 'git %s failed (%d): %s' % (args[0], p.returncode, p.stderr.decode('utf-8', 'replace').strip()[-400:]))
|
|
42
|
+
return p
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def text(cwd, *args, **kw):
|
|
46
|
+
return git(cwd, *args, **kw).stdout.decode('utf-8', 'surrogateescape').strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tip(cwd, ref):
|
|
50
|
+
p = git(cwd, 'rev-parse', '-q', '--verify', ref + '^{commit}', ok=(0, 1, 128))
|
|
51
|
+
return p.stdout.decode().strip() if p.returncode == 0 else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ancestor(cwd, a, b):
|
|
55
|
+
return git(cwd, 'merge-base', '--is-ancestor', a, b, ok=(0, 1)).returncode == 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def merge(repo, ours, theirs, message):
|
|
59
|
+
"""A merge commit of theirs into ours when the merge is clean, else (None, conflicting paths)."""
|
|
60
|
+
r = git(repo, 'merge-tree', '--write-tree', '--name-only', '--no-messages', '-z', ours, theirs, ok=(0, 1))
|
|
61
|
+
parts = r.stdout.decode('utf-8', 'surrogateescape').split('\0')
|
|
62
|
+
if r.returncode == 1:
|
|
63
|
+
return None, sorted(set(p for p in parts[1:] if p))
|
|
64
|
+
tree = parts[0].strip()
|
|
65
|
+
return text(repo, 'commit-tree', tree, '-p', ours, '-p', theirs, '-m', message), []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def push(repo, hub, sha, branch, old):
|
|
69
|
+
"""Moves the hub's branch from old (None: create) to sha, never by force; False when it moved meanwhile."""
|
|
70
|
+
lease = '--force-with-lease=refs/heads/%s:%s' % (branch, old or '')
|
|
71
|
+
p = git(repo, 'push', '-q', '--no-verify', lease, hub, '%s:refs/heads/%s' % (sha, branch), ok=(0, 1))
|
|
72
|
+
return p.returncode == 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_branch(hub, wanted):
|
|
76
|
+
if wanted:
|
|
77
|
+
return wanted
|
|
78
|
+
head = git(hub, 'symbolic-ref', '-q', 'HEAD', ok=(0, 1)).stdout.decode().strip()
|
|
79
|
+
if head.startswith('refs/heads/') and tip(hub, head):
|
|
80
|
+
return head[len('refs/heads/'):]
|
|
81
|
+
for name in ('main', 'master'):
|
|
82
|
+
if tip(hub, 'refs/heads/' + name):
|
|
83
|
+
return name
|
|
84
|
+
return head[len('refs/heads/'):] if head.startswith('refs/heads/') else 'main'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def land(a):
|
|
88
|
+
hub, result, title = a['hub'], a['result'], a.get('title') or a['result']
|
|
89
|
+
sources = [s for s in a.get('sources', []) if s and s != result]
|
|
90
|
+
into = default_branch(hub, a.get('default'))
|
|
91
|
+
work = tempfile.mkdtemp(prefix='shard-result-')
|
|
92
|
+
try:
|
|
93
|
+
git(work, 'init', '-q', '--bare')
|
|
94
|
+
wanted = [result, into] + sources
|
|
95
|
+
refs = ['+refs/heads/%s:refs/heads/%s' % (b, b) for b in wanted if tip(hub, 'refs/heads/' + b)]
|
|
96
|
+
if refs:
|
|
97
|
+
git(work, 'fetch', '-q', '--no-tags', hub, *refs)
|
|
98
|
+
have = {b: tip(work, 'refs/heads/' + b) for b in wanted}
|
|
99
|
+
before = have.get(into)
|
|
100
|
+
t, created = have.get(result), False
|
|
101
|
+
if not t:
|
|
102
|
+
# A master that did not merge its workers: merge them here, each only when it merges cleanly.
|
|
103
|
+
for s in sources:
|
|
104
|
+
st = have.get(s)
|
|
105
|
+
if not st:
|
|
106
|
+
continue
|
|
107
|
+
if not t or ancestor(work, t, st):
|
|
108
|
+
t = st
|
|
109
|
+
elif not ancestor(work, st, t):
|
|
110
|
+
t, conflicts = merge(work, t, st, 'Merge %s into %s' % (s, result))
|
|
111
|
+
if not t:
|
|
112
|
+
return {'state': 'conflict', 'stage': 'integrate', 'into': into, 'source': s, 'conflicts': conflicts}
|
|
113
|
+
if not t:
|
|
114
|
+
return {'state': 'missing', 'into': into, 'message': 'the project hub has no %s and no worker branch to make it from' % result}
|
|
115
|
+
if not push(work, hub, t, result, None):
|
|
116
|
+
return {'state': 'raced', 'stage': 'integrate', 'into': into, 'message': '%s appeared on the project hub meanwhile' % result}
|
|
117
|
+
created = True
|
|
118
|
+
if before and ancestor(work, t, before):
|
|
119
|
+
method, after = 'already', before
|
|
120
|
+
elif not before or ancestor(work, before, t):
|
|
121
|
+
method, after = ('created' if not before else 'ff'), t
|
|
122
|
+
else:
|
|
123
|
+
after, conflicts = merge(work, before, t, 'Land %s (%s)' % (title, result))
|
|
124
|
+
if not after:
|
|
125
|
+
return {'state': 'conflict', 'stage': 'default', 'into': into, 'result': t, 'before': before, 'conflicts': conflicts}
|
|
126
|
+
method = 'merge'
|
|
127
|
+
if after != before and not push(work, hub, after, into, before):
|
|
128
|
+
return {'state': 'raced', 'stage': 'default', 'into': into, 'result': t, 'message': '%s moved on the project hub meanwhile' % into}
|
|
129
|
+
# Checked on the hub itself: the result is in the branch it landed in.
|
|
130
|
+
now = tip(hub, 'refs/heads/' + into)
|
|
131
|
+
if not now or not ancestor(hub, t, now):
|
|
132
|
+
raise Fail('unverified', '%s does not contain %s on the project hub after landing' % (into, t))
|
|
133
|
+
contained = [s for s in sources if have.get(s) and ancestor(hub, have[s], t)]
|
|
134
|
+
base = (text(hub, 'merge-base', before, t, ok=(0, 1)) or None) if before else None
|
|
135
|
+
return {'state': 'landed', 'into': into, 'before': before, 'after': now, 'result': t, 'method': method,
|
|
136
|
+
'created_result': created, 'contained': contained, 'merge_base': base}
|
|
137
|
+
finally:
|
|
138
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main():
|
|
142
|
+
try:
|
|
143
|
+
out = land(json.load(sys.stdin))
|
|
144
|
+
except Fail as e:
|
|
145
|
+
out = {'state': 'error', 'reason': e.reason, 'message': str(e)}
|
|
146
|
+
sys.stdout.write(json.dumps(out) + '\n')
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == '__main__':
|
|
150
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Transcript } from '@shardflux/sdk';
|
|
2
|
+
import type { Handler } from './commands.js';
|
|
3
|
+
/** What one transcription holds (the API's bound; past it the rest is not transcribed and `truncated` says so). */
|
|
4
|
+
export declare const TRANSCRIBE_MAX_BYTES: number;
|
|
5
|
+
/** The readable form: the text, then (with --words) one line per word with its start and end. */
|
|
6
|
+
export declare function transcriptText(t: Transcript, words: boolean): string;
|
|
7
|
+
export declare const TRANSCRIBE_HANDLERS: Record<string, Handler>;
|