@shardflux/cli 0.28.2 → 0.28.3
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 +20 -0
- package/README.md +34 -3
- package/dist/args.d.ts +1 -1
- package/dist/args.js +12 -1
- package/dist/board-args.js +2 -1
- package/dist/board-commands.js +5 -2
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +5 -5
- package/dist/credential-device-capability.d.ts +8 -0
- package/dist/credential-device-capability.js +21 -0
- package/dist/credential-setup.d.ts +13 -0
- package/dist/credential-setup.js +170 -0
- package/dist/credential-sources.d.ts +34 -0
- package/dist/credential-sources.js +400 -0
- package/dist/device-bridge.d.ts +8 -1
- package/dist/device-bridge.js +45 -2
- package/dist/device-helper.js +15 -7
- package/dist/device-mode.d.ts +7 -0
- package/dist/device-mode.js +13 -0
- package/dist/device-outcomes.d.ts +3 -0
- package/dist/device-outcomes.js +19 -0
- package/dist/handler-loader.js +2 -1
- package/dist/handlers.js +2 -1
- package/dist/land.d.ts +4 -1
- package/dist/land.js +32 -3
- package/dist/machine-setup.d.ts +1 -0
- package/dist/machine-setup.js +5 -2
- package/dist/main.js +30 -2
- package/dist/orchestrator-args.js +11 -1
- package/dist/orchestrator-commands.js +44 -3
- package/dist/task-auto-land.d.ts +22 -0
- package/dist/task-auto-land.js +162 -0
- package/dist/task-land-hub.py +6 -0
- package/dist/vault.js +3 -8
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- The Shardflux app 0.9.4 (0.28.3+): `shard` installs and updates `@shardflux/desktop-darwin-arm64` / `-x64` and
|
|
6
|
+
`@shardflux/desktop-linux-x64` / `-arm64` 0.9.4 (files with a ⌘2 task, ⌘2 Credentials and Set up from this Mac,
|
|
7
|
+
talking in Safari, new answers stay visible, device request outcomes).
|
|
8
|
+
|
|
9
|
+
- Device request outcomes (0.28.3+, SDK 0.30.3): `shard orchestrator show <input-id>` and `shard task show <task-id>` show device requests, paths and recorded errors, failures first. Device activity keeps the actual error before API delivery.
|
|
10
|
+
|
|
11
|
+
- Input attachments (0.28.3+, SDK 0.30.3): `shard orchestrator say --file <path>` attaches files; repeat the flag, or use `--file - --file-name <name>` to stream stdin. Files-only inputs are supported.
|
|
12
|
+
|
|
13
|
+
- Automatic task landing (0.28.3+): the device helper automatically lands a finished board task's tested result in its linked repository even when the desktop board is closed. It respects Auto land and project landing settings, keeps local edits, waits for a missing folder, retries an interrupted receipt, and records the board only after delivery. Conflicts and unfinished master integration wait for a new result instead of repeating a steer after a service restart.
|
|
14
|
+
|
|
15
|
+
- Linux helper shutdown (0.28.3+): short Linux commands shut down their command-lifetime helper cleanly; service helpers leave stdin with the foreground command.
|
|
16
|
+
|
|
17
|
+
- Task results (0.28.3+, SDK 0.30.3): keep canonical task-result links and automatic resend metadata in orchestrator input output.
|
|
18
|
+
|
|
19
|
+
- Coordinator questions (0.28.3+, SDK 0.30.3): input reads preserve a coordinator wait and its message; internal worker blockers reach their coordinator before a question reaches the person. Existing needs-you answer commands remain unchanged.
|
|
20
|
+
|
|
21
|
+
- Credential setup (0.28.3+, SDK 0.30.3): `shard credentials setup --from-this-computer` brings existing shell, project and login credentials along automatically. `--if-needed` leaves unchanged values alone; `--plan` offers an optional names-only review. Connected agents can request the same secure local transfer through the device helper.
|
|
22
|
+
|
|
23
|
+
- Credential errors (0.28.3+): write-only credential failures never copy upstream prose or details into output, including values shorter than eight bytes and JSON-escaped values. Status and request id remain available.
|
|
24
|
+
|
|
5
25
|
- The Shardflux app 0.9.3 (0.28.2+): `shard` installs and updates `@shardflux/desktop-darwin-arm64` / `-x64` and
|
|
6
26
|
`@shardflux/desktop-linux-x64` / `-arm64` 0.9.3 (files open beside your work, pictures and videos picture in picture,
|
|
7
27
|
⌘2 shows the agents your orchestrator starts, needs-you questions read whole).
|
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ and restarts after a CLI update. It keeps the live twin, `~/Shardflux` files and
|
|
|
101
101
|
Use `shard device requests`, `shard device approve <id>` or `shard device deny <id>` for local approvals, and
|
|
102
102
|
`shard device approvals auto|read|ask` to choose whether your agents ask first (0.28.0+);
|
|
103
103
|
desktop notifications point to pending requests. On systems without a systemd user session, the helper runs
|
|
104
|
-
while your `shard` commands are open. `shard agents open` opens a terminal when launched outside one, using
|
|
104
|
+
while your `shard` commands are open; their input stays with the foreground command. `shard agents open` opens a terminal when launched outside one, using
|
|
105
105
|
`$TERMINAL`, `x-terminal-emulator` or `gnome-terminal`, or prints the attach command. On macOS the same app lives in
|
|
106
106
|
the menu bar.
|
|
107
107
|
|
|
@@ -263,6 +263,9 @@ Finished cloud work lands in your folder by itself: a conversation's commits com
|
|
|
263
263
|
board task's result into your linked folder and the project's default branch. `shard settings --auto-land off`
|
|
264
264
|
(0.28.0+) keeps it in the cloud on this computer (`shard task land` and `shard conversations land` still land on
|
|
265
265
|
request); `--auto-land on` turns it back on. The Shardflux app's Settings → Projects changes the same switch.
|
|
266
|
+
The device helper brings finished task results back while your computer is reachable, including work requested from
|
|
267
|
+
your phone; it catches up after your computer reconnects. Your local edits stay in place, and a conflicting merge
|
|
268
|
+
waits for a decision instead of overwriting them.
|
|
266
269
|
|
|
267
270
|
Terminal conversations open in the terminal you choose (0.22.0+):
|
|
268
271
|
|
|
@@ -1529,8 +1532,10 @@ your agent uses the matching machine or project save/rollback commands in its so
|
|
|
1529
1532
|
`shard machine open` (0.17.1+): it creates or resumes the machine VM without attaching and prints its key for
|
|
1530
1533
|
`shard ws exec` and `shard files write`. `shard machine setup --from-this-computer` (0.20.0+) does it for you: your own
|
|
1531
1534
|
Claude Code (`claude -p`, else Codex) recreates this computer's CLIs and tools, language runtimes, shell config and
|
|
1532
|
-
aliases, git identity and dotfiles on the machine, on your subscription, then saves it.
|
|
1533
|
-
`~/.ssh`, keychains, `.env`, token and credential files
|
|
1535
|
+
aliases, git identity and dotfiles on the machine, on your subscription, then saves it. That setup agent reads
|
|
1536
|
+
tool and configuration metadata and excludes `~/.ssh`, keychains, `.env`, token and credential files. Optional
|
|
1537
|
+
`--with-credentials` (0.28.3+) runs the trusted local credential transfer afterwards; onboarding includes that transfer.
|
|
1538
|
+
The apps and the first run start machine setup once,
|
|
1534
1539
|
right after you connect your agents. `shard machine setup --status` shows the machine as your account has it, in the
|
|
1535
1540
|
same line the apps show: ready, saving (with its place in the queue), or the one command to run next (0.22.0+); `shard
|
|
1536
1541
|
setup --inspect --json` carries it as `onboarding.machine`. `connections_ready` (and the legacy `ready` field) means
|
|
@@ -2100,3 +2105,29 @@ Bare `shard` in a coding-agent shell or without a TTY returns its setup guide us
|
|
|
2100
2105
|
Needs-you output prints each question and its optional `option_details` in full, including what each choice does and what follows it. The first suggested answer is the agent’s recommendation. Secret values go through the project’s secure setup, never a question or its ordinary answer.
|
|
2101
2106
|
|
|
2102
2107
|
Conversation creation (0.28.2+) includes the optional launching conversation only when `/v1/meta` advertises `capabilities.conversation_origin: true`. Older APIs receive the existing create body; no rejected create is replayed with different fields.
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
## Input attachments (0.28.3+)
|
|
2111
|
+
|
|
2112
|
+
`shard orchestrator say --file <path>` attaches files; repeat the flag, or use `--file - --file-name <name>` to stream stdin. Files-only inputs are supported.
|
|
2113
|
+
|
|
2114
|
+
```sh
|
|
2115
|
+
shard orchestrator say "Review these" --file spec.pdf --file photo.png
|
|
2116
|
+
cat archive.tar | shard orchestrator say --file - --file-name archive.tar
|
|
2117
|
+
```
|
|
2118
|
+
File uploads stream to durable API storage, then the input carries their pinned references. Each receiving thread gets the files before its turn, under `~/inputs/`.
|
|
2119
|
+
|
|
2120
|
+
### Device request outcomes (0.28.3+)
|
|
2121
|
+
|
|
2122
|
+
```sh
|
|
2123
|
+
shard orchestrator show in_abcdefghijklmnopqrstuvwxyz
|
|
2124
|
+
shard task show task_abcdefghijklmnopqrstuvwxyz
|
|
2125
|
+
```
|
|
2126
|
+
|
|
2127
|
+
Each request shows what ran, its paths and outcome, and its recorded error. Failures appear first. Add `--json` for the full detail object. Reading starts no computer.
|
|
2128
|
+
|
|
2129
|
+
Scoped vault imports (0.28.3+) use `shard secrets import NAME --from hsec --scope project --project PROJECT` (or `--scope agents --org ORG` while signed in). Re-importing an identical value leaves its version unchanged; a changed value rotates it. The vault sends the value directly to the encrypted write-only API. Failures show a safe status and request id without upstream prose or details that could echo the value.
|
|
2130
|
+
|
|
2131
|
+
## Credential setup (0.28.3+)
|
|
2132
|
+
|
|
2133
|
+
Run `shard credentials setup --from-this-computer --if-needed` after machine setup to bring your existing credentials along. Machine credentials are available to all your agents; project `.env` credentials stay local and override machine values by name. Values transfer directly from the local source into the write-only secret store and private credential files. The command prints names and delivery progress. Use `--plan` for an optional review, then `--apply PLAN_ID`. If a requested credential is missing everywhere, your agent asks once through the secure credential field.
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -15,7 +15,7 @@ import { BOARD_COMMANDS } from "./board-args.js";
|
|
|
15
15
|
import { ORCHESTRATOR_COMMANDS } from "./orchestrator-args.js";
|
|
16
16
|
import { UsageError } from "./errors.js";
|
|
17
17
|
import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
|
|
18
|
-
export const CLI_VERSION = '0.28.
|
|
18
|
+
export const CLI_VERSION = '0.28.3';
|
|
19
19
|
export const GLOBAL_OPTIONS = {
|
|
20
20
|
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
21
21
|
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
@@ -244,6 +244,7 @@ const KEY_COMMANDS = [
|
|
|
244
244
|
{ path: ['machine', 'setup'], summary: 'Let your own Claude Code or Codex set up your machine from this computer (0.20.0+)', positionals: [], options: {
|
|
245
245
|
'from-this-computer': { type: 'boolean', description: 'Recreate this computer’s development setup on your machine: its CLIs and tools, language runtimes, shell config and aliases, git identity and dotfiles. Required.' },
|
|
246
246
|
'if-needed': { type: 'boolean', description: 'Only when your machine was never set up from this computer (what the apps run after onboarding).' },
|
|
247
|
+
'with-credentials': { type: 'boolean', description: 'After the machine is saved, bring existing credentials along with the trusted local credential setup.' },
|
|
247
248
|
status: { type: 'boolean', description: 'Show the last setup from this computer instead of running one.' },
|
|
248
249
|
agent: { type: 'string', value: '<claude|codex|cloud>', description: 'Which agent sets it up (default: this computer’s Claude Code, else its Codex, else your cloud Claude in the machine VM).' },
|
|
249
250
|
timeout: { type: 'string', value: '<duration>', description: 'Time box for the agent (default 10m).' },
|
|
@@ -982,6 +983,16 @@ const KEY_COMMANDS = [
|
|
|
982
983
|
examples: ['shard executions get acme/agent-7 build-42', 'shard ws executions get acme/agent-7 ex-6f1c2a9e-0d7b-4c1e-9a53-2b8e1f0c4d7a --wait 10m --json'],
|
|
983
984
|
},
|
|
984
985
|
{ path: ['secrets', 'log'], summary: 'Show credential request and fill audit events', positionals: [], options: {} },
|
|
986
|
+
{ path: ['credentials', 'setup'], summary: 'Bring existing credentials from this computer to your machine and project', positionals: [], options: {
|
|
987
|
+
'from-this-computer': { type: 'boolean', description: 'Discover source names locally and transfer values directly; no value goes to a model.' },
|
|
988
|
+
'if-needed': { type: 'boolean', description: 'Reconcile existing sources; report needed:false when their stored versions are unchanged.' },
|
|
989
|
+
plan: { type: 'boolean', description: 'Review the names and scopes once before transferring selected sources.' },
|
|
990
|
+
apply: { type: 'string', value: '<plan-id>', description: 'Continue a reviewed setup using its original source descriptors.' },
|
|
991
|
+
deny: { type: 'string', value: '<plan-id>', description: 'Decline a reviewed setup.' },
|
|
992
|
+
picks: { type: 'string', value: '<file|->', description: 'JSON source IDs and scopes for --apply; - reads stdin. No values.' },
|
|
993
|
+
project: { type: 'string', value: '<project-id>', description: 'Project for local credentials (default current context).' },
|
|
994
|
+
path: { type: 'string', value: '<folder>', description: 'Project folder whose .env files to discover (default current folder).' },
|
|
995
|
+
} },
|
|
985
996
|
{ path: ['credentials', 'list'], summary: 'Show pending login requests', positionals: [], options: {} },
|
|
986
997
|
{ path: ['credentials', 'enable'], summary: 'Enable credential fill in a workspace', positionals: [REF], options: {} },
|
|
987
998
|
{ path: ['credentials', 'deny'], summary: 'Deny a login request', positionals: [{ name: 'request-id', required: true }], options: {} },
|
package/dist/board-args.js
CHANGED
|
@@ -84,7 +84,8 @@ export const BOARD_COMMANDS = [
|
|
|
84
84
|
path: ['task', 'land'],
|
|
85
85
|
summary: 'Land a finished task: its result into your folder and the project’s default branch (0.25.0+)',
|
|
86
86
|
positionals: [{ name: 'id', required: true }],
|
|
87
|
-
options: { path: { type: 'string', value: '<folder>', description: 'The folder to land in (default: the project’s folder on this computer, if any).' }
|
|
87
|
+
options: { path: { type: 'string', value: '<folder>', description: 'The folder to land in (default: the project’s folder on this computer, if any).' },
|
|
88
|
+
automatic: { type: 'boolean', hidden: true, description: 'Device automatic delivery: require Auto land, a linked folder and the master’s tested result.' } },
|
|
88
89
|
description: [
|
|
89
90
|
'A task has one result: the branch shard/<task id>, where its master merged its workers’ branches. Landing merges it',
|
|
90
91
|
'into the project’s default branch on the project hub (a fast-forward, or a merge commit when the merge is clean),',
|
package/dist/board-commands.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deviceOutcomeLines } from "./device-outcomes.js";
|
|
1
2
|
import { findOrg, resolveOrg } from "./account-scope.js";
|
|
2
3
|
import { out, principal, str, strs, readSecretValue, askSecretValue } from "./commands.js";
|
|
3
4
|
import { detailLine, pickAnswers } from "./conversation-commands.js";
|
|
@@ -150,6 +151,8 @@ export function taskDetailText(d) {
|
|
|
150
151
|
lines.push(` Landed: ${r.landed.how} ${r.landed.ref}`);
|
|
151
152
|
}
|
|
152
153
|
}
|
|
154
|
+
if (d.device_requests !== undefined)
|
|
155
|
+
lines.push(...deviceOutcomeLines(d.device_requests));
|
|
153
156
|
return `${lines.join('\n')}\n`;
|
|
154
157
|
}
|
|
155
158
|
/** --now, --next or --later (one at most); undefined when none was given. */
|
|
@@ -350,7 +353,7 @@ export const BOARD_HANDLERS = {
|
|
|
350
353
|
return 0;
|
|
351
354
|
},
|
|
352
355
|
async 'task land'(ctx, values, [id]) {
|
|
353
|
-
const o = await landTask(landDeps(ctx), id, { path: str(values.path) });
|
|
356
|
+
const o = await landTask(landDeps(ctx), id, { path: str(values.path), automatic: values.automatic === true });
|
|
354
357
|
out(ctx, landedText(o), o);
|
|
355
358
|
return 0;
|
|
356
359
|
},
|
|
@@ -391,7 +394,7 @@ export const BOARD_HANDLERS = {
|
|
|
391
394
|
await findOrg(ctx.account(), organizationId);
|
|
392
395
|
}
|
|
393
396
|
const save = (value) => {
|
|
394
|
-
ctx.
|
|
397
|
+
ctx.addWriteOnlyValue(value);
|
|
395
398
|
return cloud.needsYou.answer(id, { credential: { value, scope: target } }, { ...(organizationId ? { organizationId } : {}), signal: ctx.signal });
|
|
396
399
|
};
|
|
397
400
|
const value = await readSecretValue(ctx, values, item.credential.name);
|
package/dist/commands.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface Ctx {
|
|
|
61
61
|
};
|
|
62
62
|
/** A credential this command received (redacted from error output). */
|
|
63
63
|
addSecret(value: string): void;
|
|
64
|
+
/** A credential value this command writes: redacted like addSecret, and failures report only status-based codes. */
|
|
65
|
+
addWriteOnlyValue(value: string): void;
|
|
64
66
|
/** The update status for `shard version` (forced: ask the API now). Null when unknown. */
|
|
65
67
|
versionStatus(force: boolean): Promise<UpdateView | null>;
|
|
66
68
|
/** The credentials file (config directory from the environment). */
|
package/dist/commands.js
CHANGED
|
@@ -2349,9 +2349,9 @@ export const KEY_HANDLERS = {
|
|
|
2349
2349
|
const project = agent ? null : (await principal(ctx)).project_id;
|
|
2350
2350
|
const imported = [];
|
|
2351
2351
|
for (const [i, name] of names.entries()) {
|
|
2352
|
-
const value = await vaultValue(from, name, { signal: ctx.signal, ...(keychain ? { keychain } : {}) });
|
|
2353
|
-
ctx.
|
|
2354
|
-
imported.push(agent ? await agent.cloud.secrets.
|
|
2352
|
+
const value = await vaultValue(from, name, { signal: ctx.signal, env: ctx.io.env, ...(keychain ? { keychain } : {}) });
|
|
2353
|
+
ctx.addWriteOnlyValue(value);
|
|
2354
|
+
imported.push(agent ? await agent.cloud.secrets.importAgent(agent.target, { name: targets[i], value }) : await ctx.cloud().secrets.create(project, { name: targets[i], value }));
|
|
2355
2355
|
}
|
|
2356
2356
|
out(ctx, `Imported ${imported.map(s => s.name).join(', ')}. ${SECRET_SECURITY_NOTE}\n`, { data: imported, security_note: SECRET_SECURITY_NOTE });
|
|
2357
2357
|
return 0;
|
|
@@ -2612,7 +2612,7 @@ async function agentSecretCommand(ctx, values, action, ref) {
|
|
|
2612
2612
|
if (action === 'create') {
|
|
2613
2613
|
if (!SECRET_NAME.test(ref ?? ''))
|
|
2614
2614
|
throw new UsageError('Use an environment variable name for the credential.');
|
|
2615
|
-
const value = await readSecretValue(ctx, values, ref), save = (value) => { ctx.
|
|
2615
|
+
const value = await readSecretValue(ctx, values, ref), save = (value) => { ctx.addWriteOnlyValue(value); return cloud.secrets.createAgent(target, { name: ref, value, ...(str(values.description) !== undefined ? { description: str(values.description) } : {}) }); };
|
|
2616
2616
|
const r = value !== null ? await save(value) : await askSecretValue(ctx, values, { title: `Add ${ref}`, purpose: `Saved encrypted for your agents as ${ref}.`, name: ref }, save);
|
|
2617
2617
|
out(ctx, describe(r), r);
|
|
2618
2618
|
return 0;
|
|
@@ -2636,7 +2636,7 @@ async function agentSecretCommand(ctx, values, action, ref) {
|
|
|
2636
2636
|
return 0;
|
|
2637
2637
|
}
|
|
2638
2638
|
if (action === 'rotate') {
|
|
2639
|
-
const value = await readSecretValue(ctx, values, r.name), save = (value) => { ctx.
|
|
2639
|
+
const value = await readSecretValue(ctx, values, r.name), save = (value) => { ctx.addWriteOnlyValue(value); return cloud.secrets.rotateAgent(r.id, value); };
|
|
2640
2640
|
const changed = value !== null ? await save(value) : await askSecretValue(ctx, values, { title: `New value for ${r.name}`, purpose: `Replaces the encrypted credential ${r.name}.`, name: r.name }, save);
|
|
2641
2641
|
out(ctx, describe(changed), changed);
|
|
2642
2642
|
return 0;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
import { conversationClient } from './conversation-commands.js';
|
|
3
|
+
type Input = Parameters<ReturnType<typeof conversationClient>['deviceRequests']['heartbeat']>[0];
|
|
4
|
+
export declare function credentialHeartbeat(ctx: Ctx, input: Input): Promise<{
|
|
5
|
+
device_id: string;
|
|
6
|
+
online_until: string;
|
|
7
|
+
}>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Old APIs must keep accepting ordinary helper heartbeats during a rolling upgrade. */
|
|
2
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
3
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
4
|
+
const probes = new WeakMap();
|
|
5
|
+
export async function credentialHeartbeat(ctx, input) {
|
|
6
|
+
let probe = probes.get(ctx);
|
|
7
|
+
if (!probe || Date.now() - probe.at >= 30_000) {
|
|
8
|
+
probe = { at: Date.now(), pending: conversationClient(ctx).credentialSetups.available().catch(() => false) };
|
|
9
|
+
probes.set(ctx, probe);
|
|
10
|
+
}
|
|
11
|
+
const available = await probe.pending, cloud = conversationClient(ctx);
|
|
12
|
+
try {
|
|
13
|
+
return await cloud.deviceRequests.heartbeat({ ...input, ...(available ? { capabilities: ['credentials_setup.v1'] } : {}) });
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (!available || !(e instanceof ShardfluxApiError && [400, 422].includes(e.status)))
|
|
17
|
+
throw e;
|
|
18
|
+
probes.set(ctx, { at: Date.now(), pending: Promise.resolve(false) });
|
|
19
|
+
return cloud.deviceRequests.heartbeat(input);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CredentialSetup, CredentialSetupsApi, SecretsApi, NeedsYouApi } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
import type { CredentialDiscoveryOptions } from './credential-sources.js';
|
|
4
|
+
interface SetupClient {
|
|
5
|
+
credentialSetups: CredentialSetupsApi;
|
|
6
|
+
secrets: SecretsApi;
|
|
7
|
+
needsYou: NeedsYouApi;
|
|
8
|
+
}
|
|
9
|
+
/** Onboarding opts in explicitly. The restricted machine agent never reads the source values. */
|
|
10
|
+
export declare function credentialSetupAfterMachine(ctx: Ctx): Promise<void>;
|
|
11
|
+
export declare function transferCredentialSetup(ctx: Ctx, cloud: SetupClient, plan: CredentialSetup, options: CredentialDiscoveryOptions): Promise<CredentialSetup>;
|
|
12
|
+
export declare const CREDENTIAL_SETUP_HANDLERS: Record<string, Handler>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Trusted local credential transfer. Models and stdout receive names, sources and status only. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { UsageError } from "./errors.js";
|
|
5
|
+
import { captureLocalLoginEnvironment, discoverCredentialSources, readCredentialSource, CredentialSourceError } from "./credential-sources.js";
|
|
6
|
+
const str = (value) => typeof value === 'string' ? value : undefined;
|
|
7
|
+
function client(ctx) { return ctx.sessionSource() ? ctx.account() : ctx.cloud(); }
|
|
8
|
+
/** Onboarding opts in explicitly. The restricted machine agent never reads the source values. */
|
|
9
|
+
export async function credentialSetupAfterMachine(ctx) {
|
|
10
|
+
try {
|
|
11
|
+
const cloud = client(ctx);
|
|
12
|
+
if (!cloud.credentialSetups || !await cloud.credentialSetups.available())
|
|
13
|
+
return;
|
|
14
|
+
const quiet = { ...ctx, json: true, io: { ...ctx.io, stdout: { ...ctx.io.stdout, write: () => true } } };
|
|
15
|
+
await CREDENTIAL_SETUP_HANDLERS['credentials setup'](quiet, { 'from-this-computer': true, 'if-needed': true }, []);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
ctx.io.stderr.write('Credential setup is pending. Continue with shard credentials setup --from-this-computer --if-needed.\n');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const itemResult = (item) => ({ id: item.id, name: item.name, state: item.state, scope: item.selected_scope, project_id: item.project_id, ...(item.delivery ? { delivery: item.delivery } : {}), ...(item.error_code ? { error_code: item.error_code } : {}) });
|
|
22
|
+
function output(ctx, value) { if (ctx.json)
|
|
23
|
+
ctx.io.stdout.write(JSON.stringify(value) + '\n'); }
|
|
24
|
+
export async function transferCredentialSetup(ctx, cloud, plan, options) {
|
|
25
|
+
if (plan.state === 'awaiting_approval')
|
|
26
|
+
return plan;
|
|
27
|
+
if (plan.state === 'denied' || plan.state === 'completed')
|
|
28
|
+
return plan;
|
|
29
|
+
const jobs = plan.items.filter(i => i.state === 'pending' && i.selected_scope !== null).map(async (item) => {
|
|
30
|
+
try {
|
|
31
|
+
const { project_id: sourceProject, ...descriptor } = item;
|
|
32
|
+
const candidate = { ...descriptor, ...(sourceProject ? { project_id: sourceProject } : {}), preferred: true };
|
|
33
|
+
const value = await readCredentialSource(candidate, options);
|
|
34
|
+
ctx.addWriteOnlyValue(value);
|
|
35
|
+
const stored = await cloud.credentialSetups.storeValue(plan.id, item.id, value);
|
|
36
|
+
const result = stored.items.find(i => i.id === item.id);
|
|
37
|
+
if (result)
|
|
38
|
+
output(ctx, { plan_id: plan.id, result: itemResult(result) });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const code = err instanceof CredentialSourceError ? err.code : 'import_failed';
|
|
42
|
+
try {
|
|
43
|
+
const failed = await cloud.credentialSetups.sourceResult(plan.id, item.id, code);
|
|
44
|
+
const result = failed.items.find(i => i.id === item.id);
|
|
45
|
+
if (result)
|
|
46
|
+
output(ctx, { plan_id: plan.id, result: itemResult(result) });
|
|
47
|
+
}
|
|
48
|
+
catch { /* Read authoritative status below; a lost write response may already have stored it. */ }
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
await Promise.allSettled(jobs);
|
|
52
|
+
return cloud.credentialSetups.get(plan.id, { signal: ctx.signal });
|
|
53
|
+
}
|
|
54
|
+
async function picks(ctx, path) {
|
|
55
|
+
let value;
|
|
56
|
+
if (path === '-') {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
for await (const chunk of ctx.io.stdin)
|
|
59
|
+
chunks.push(Buffer.from(chunk));
|
|
60
|
+
value = Buffer.concat(chunks).toString('utf8');
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
value = await readFile(path, 'utf8');
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(value);
|
|
66
|
+
if (!Array.isArray(parsed) || !parsed.every((item) => {
|
|
67
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
68
|
+
return false;
|
|
69
|
+
const i = item;
|
|
70
|
+
return typeof i.id === 'string' && (i.scope === 'agents' || i.scope === 'project') && (i.project_id === undefined || typeof i.project_id === 'string') && Object.keys(i).every(k => ['id', 'scope', 'project_id'].includes(k));
|
|
71
|
+
}))
|
|
72
|
+
throw new Error();
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new UsageError('--picks takes a JSON list of discovered source IDs and scopes.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export const CREDENTIAL_SETUP_HANDLERS = {
|
|
80
|
+
async 'credentials setup'(ctx, values) {
|
|
81
|
+
const cloud = client(ctx), apply = str(values.apply), deny = str(values.deny), path = str(values.path);
|
|
82
|
+
if (!await cloud.credentialSetups.available())
|
|
83
|
+
throw new UsageError('Credential setup is being prepared. Update Shardflux and try again.');
|
|
84
|
+
if (apply && deny)
|
|
85
|
+
throw new UsageError('Choose --apply or --deny.');
|
|
86
|
+
if (deny) {
|
|
87
|
+
await cloud.needsYou.answer('csetup_' + deny, { decision: 'deny' });
|
|
88
|
+
output(ctx, { plan_id: deny, state: 'denied' });
|
|
89
|
+
if (!ctx.json)
|
|
90
|
+
ctx.io.stdout.write('Credential setup declined.\n');
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
if (!apply && values['from-this-computer'] !== true)
|
|
94
|
+
throw new UsageError('Use --from-this-computer to bring your existing credentials along.');
|
|
95
|
+
let projectId = str(values.project) ?? ctx.store.profile(ctx.apiUrl).project_id, organizationId = ctx.store.profile(ctx.apiUrl).organization_id;
|
|
96
|
+
if (!projectId && !apply) {
|
|
97
|
+
try {
|
|
98
|
+
const me = await ctx.cloud().me();
|
|
99
|
+
if (!me.api_key)
|
|
100
|
+
throw new UsageError('Select a project with --project.');
|
|
101
|
+
projectId = me.api_key.project_id;
|
|
102
|
+
organizationId ??= me.api_key.organization_id;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
if (!ctx.sessionSource())
|
|
106
|
+
throw new UsageError('Select a project with --project, or sign in first.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const home = ctx.io.env.HOME ?? homedir();
|
|
110
|
+
const base = { home, env: ctx.io.env, ...(projectId ? { projectId, projectPath: path ?? process.cwd() } : {}), signal: ctx.signal };
|
|
111
|
+
const env = await captureLocalLoginEnvironment(base), options = { ...base, env };
|
|
112
|
+
let plan, prior = new Map();
|
|
113
|
+
if (apply) {
|
|
114
|
+
const selection = str(values.picks);
|
|
115
|
+
if (selection)
|
|
116
|
+
await cloud.needsYou.answer('csetup_' + apply, { credential_setup: { items: await picks(ctx, selection) } });
|
|
117
|
+
plan = await cloud.credentialSetups.get(apply, { signal: ctx.signal });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const candidates = await discoverCredentialSources(options), localOnly = !ctx.sessionSource();
|
|
121
|
+
const normalized = (localOnly ? [...candidates].sort((a, b) => Number(b.suggested_scope === 'project') - Number(a.suggested_scope === 'project')) : candidates).map(c => ({ ...c, ...(localOnly ? { suggested_scope: 'project', project_id: projectId } : {}) }));
|
|
122
|
+
if (localOnly && !projectId)
|
|
123
|
+
throw new UsageError('Select a project with --project.');
|
|
124
|
+
const selected = new Set(), selectedItems = [];
|
|
125
|
+
for (const c of normalized)
|
|
126
|
+
if (c.preferred) {
|
|
127
|
+
const key = JSON.stringify([c.suggested_scope, c.project_id, c.name]);
|
|
128
|
+
if (!selected.has(key)) {
|
|
129
|
+
selected.add(key);
|
|
130
|
+
selectedItems.push(c.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const items = normalized.map(({ id, name, source, suggested_scope, project_id }) => ({ id, name, source, suggested_scope, ...(project_id ? { project_id } : {}) }));
|
|
134
|
+
if (!items.length && values['if-needed'] === true) {
|
|
135
|
+
output(ctx, { needed: false, results: [] });
|
|
136
|
+
if (!ctx.json)
|
|
137
|
+
ctx.io.stdout.write('No new credential sources found.\n');
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const metadata = await cloud.secrets.listAgents({ ...organizationId ? { organizationId } : {} });
|
|
142
|
+
prior = new Map(metadata.map(s => [s.id, s.current_version]));
|
|
143
|
+
}
|
|
144
|
+
catch { /* An unavailable metadata read does not block the direct transfer. */ }
|
|
145
|
+
plan = await cloud.credentialSetups.create({ items, ...(projectId ? { projectId } : {}), ...(organizationId ? { organizationId } : {}), review: values.plan === true, ...(values.plan === true ? {} : { selectedItems }) });
|
|
146
|
+
if (!prior.size)
|
|
147
|
+
try {
|
|
148
|
+
prior = new Map((await cloud.secrets.listAgents({ organizationId: plan.organization_id })).map(s => [s.id, s.current_version]));
|
|
149
|
+
}
|
|
150
|
+
catch { /* Transfers remain independent of this optional metadata read. */ }
|
|
151
|
+
output(ctx, { plan_id: plan.id, ...(values.plan === true ? { candidates: plan.items } : {}) });
|
|
152
|
+
if (values.plan === true) {
|
|
153
|
+
if (!ctx.json)
|
|
154
|
+
ctx.io.stdout.write('Review the discovered names in Shard, then import the selected sources.\n');
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (plan.state === 'awaiting_approval') {
|
|
159
|
+
output(ctx, { plan_id: plan.id, state: plan.state, results: plan.items.map(itemResult) });
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
const final = await transferCredentialSetup(ctx, cloud, plan, options), failed = final.items.filter(i => i.state === 'failed').length, pending = final.items.filter(i => i.state === 'pending' || i.state === 'stored' && i.delivery?.state !== 'ready').length;
|
|
163
|
+
const stored = final.items.filter(i => i.state === 'stored'), needed = stored.some(i => !i.secret_id || prior.get(i.secret_id) !== i.secret_version);
|
|
164
|
+
const result = { plan_id: final.id, state: final.state, needed, results: final.items.map(itemResult), stored: stored.length, failed, pending };
|
|
165
|
+
output(ctx, result);
|
|
166
|
+
if (!ctx.json)
|
|
167
|
+
ctx.io.stdout.write(`${stored.length} credential${stored.length === 1 ? '' : 's'} stored${failed ? `, ${failed} source${failed === 1 ? '' : 's'} need attention` : ''}${pending ? `, ${pending} delivery pending` : ''}.\n`);
|
|
168
|
+
return failed ? 1 : 0;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type CredentialSourceCode = 'source_unavailable' | 'source_changed' | 'source_not_text' | 'source_empty' | 'source_too_large' | 'source_has_nul' | 'vault_locked' | 'login_expired' | 'import_failed';
|
|
2
|
+
export declare class CredentialSourceError extends Error {
|
|
3
|
+
readonly code: CredentialSourceCode;
|
|
4
|
+
constructor(code: CredentialSourceCode);
|
|
5
|
+
}
|
|
6
|
+
export interface CredentialCandidate {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
source: {
|
|
10
|
+
kind: 'environment' | 'dotenv' | 'shell' | 'vault' | 'cli';
|
|
11
|
+
label: string;
|
|
12
|
+
reference: string;
|
|
13
|
+
};
|
|
14
|
+
suggested_scope: 'agents' | 'project';
|
|
15
|
+
project_id?: string;
|
|
16
|
+
preferred: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface CredentialDiscoveryOptions {
|
|
19
|
+
home: string;
|
|
20
|
+
env: Readonly<NodeJS.ProcessEnv>;
|
|
21
|
+
projectPath?: string;
|
|
22
|
+
projectId?: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
/** Fixture-injected process boundary. stdout is private; stderr is never returned. */
|
|
25
|
+
run?: (argv: readonly string[], env: Readonly<NodeJS.ProcessEnv>, signal?: AbortSignal) => Promise<Buffer>;
|
|
26
|
+
/** Tests declare the fixture executables. Production resolves only the caller's PATH. */
|
|
27
|
+
availableCommands?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function credentialVariableName(name: string): boolean;
|
|
30
|
+
/** Normal local login startup resolves exported vault expressions privately, without a model reading shell files. */
|
|
31
|
+
export declare function captureLocalLoginEnvironment(options: CredentialDiscoveryOptions): Promise<NodeJS.ProcessEnv>;
|
|
32
|
+
/** Reconstruct a source from metadata only, so a review can finish in a later CLI process. */
|
|
33
|
+
export declare function readCredentialSource(candidate: CredentialCandidate, options: CredentialDiscoveryOptions): Promise<string>;
|
|
34
|
+
export declare function discoverCredentialSources(options: CredentialDiscoveryOptions): Promise<CredentialCandidate[]>;
|