@yolo-labs/yolo-cli 0.23.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/README.md +57 -0
- package/dist/artifact-get.js +131 -0
- package/dist/artifact-list.js +133 -0
- package/dist/auth-context.js +60 -0
- package/dist/canonicalizer.js +208 -0
- package/dist/cli.js +1403 -0
- package/dist/context.js +58 -0
- package/dist/deploy-bundle.js +394 -0
- package/dist/deploy-cli.js +1341 -0
- package/dist/deploy-client.js +460 -0
- package/dist/deploy-config.js +301 -0
- package/dist/deploy-detect.js +498 -0
- package/dist/deploy-ship.js +389 -0
- package/dist/lockfile.js +203 -0
- package/dist/plan-diff.js +162 -0
- package/dist/plan-export.js +261 -0
- package/dist/plan-frontmatter.schema.json +184 -0
- package/dist/plan-get.js +244 -0
- package/dist/plan-import.js +420 -0
- package/dist/plan-list.js +153 -0
- package/dist/plan-open.js +100 -0
- package/dist/plan-state.js +228 -0
- package/dist/plan-validate.js +270 -0
- package/dist/revision.js +43 -0
- package/dist/run-get.js +130 -0
- package/dist/run-lifecycle.js +133 -0
- package/dist/run-list.js +148 -0
- package/dist/run-start.js +110 -0
- package/dist/run-transfer.js +128 -0
- package/dist/serve.js +227 -0
- package/dist/tileapp-developer.js +222 -0
- package/dist/tileapp-oci-assembler.js +591 -0
- package/dist/tileapp-personal.js +461 -0
- package/dist/tileapp-publisher.js +134 -0
- package/dist/tileapp-validator.js +239 -0
- package/dist/vendored/flexdb.mjs +1087 -0
- package/dist/webapp-url.js +61 -0
- package/dist/work-client.js +195 -0
- package/package.json +39 -0
package/dist/run-list.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo run list` — list Plan Runs in the current workspace.
|
|
3
|
+
*
|
|
4
|
+
* Read companion to the run lifecycle slice. After starting/transitioning
|
|
5
|
+
* runs, an operator wants a "what runs are in this workspace?" view
|
|
6
|
+
* without remembering planRunIds. Maps to `work.list_runs` (added on
|
|
7
|
+
* the server side as part of this slice).
|
|
8
|
+
*
|
|
9
|
+
* Two output modes:
|
|
10
|
+
* - default (`--summary`): one row per run with planRunId, planId,
|
|
11
|
+
* run #, executionState, operator, startedAt.
|
|
12
|
+
* - `--json`: pretty-printed raw `runs[]` for jq pipelines.
|
|
13
|
+
*
|
|
14
|
+
* Filter: `--state <pending|running|paused|succeeded|failed|cancelled|superseded>`
|
|
15
|
+
* passed as `?executionState=` query string for server-side filtering.
|
|
16
|
+
*
|
|
17
|
+
* Exit codes:
|
|
18
|
+
* - 0 = success (zero runs is also success — empty list, not an error)
|
|
19
|
+
* - 1 = http (e.g., 401 from a revoked token)
|
|
20
|
+
* - 64 = usage (bad --state, missing env, --workspace mismatch)
|
|
21
|
+
*/
|
|
22
|
+
import { SUBSTRATE_CLI_RUN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
23
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
24
|
+
const ALLOWED_STATES = [
|
|
25
|
+
'pending',
|
|
26
|
+
'running',
|
|
27
|
+
'paused',
|
|
28
|
+
'succeeded',
|
|
29
|
+
'failed',
|
|
30
|
+
'cancelled',
|
|
31
|
+
'superseded',
|
|
32
|
+
];
|
|
33
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
34
|
+
export async function runRunList(options) {
|
|
35
|
+
const env = options.env ?? process.env;
|
|
36
|
+
const format = options.outputFormat ?? 'summary';
|
|
37
|
+
if (options.stateFilter !== undefined && !ALLOWED_STATES.includes(options.stateFilter)) {
|
|
38
|
+
return fail('usage', `invalid --state '${options.stateFilter}': must be one of ${ALLOWED_STATES.join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
const auth = resolveSubstrateContext(env);
|
|
41
|
+
if (!auth.ok)
|
|
42
|
+
return fail('auth', auth.message);
|
|
43
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
44
|
+
let mint;
|
|
45
|
+
try {
|
|
46
|
+
mint = await mintSubstrateToken({
|
|
47
|
+
commonApiUrl,
|
|
48
|
+
userToken,
|
|
49
|
+
sessionId,
|
|
50
|
+
scopes: SUBSTRATE_CLI_RUN_SCOPES,
|
|
51
|
+
fetchImpl: options.fetchImpl,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof WorkClientError) {
|
|
56
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
57
|
+
}
|
|
58
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
59
|
+
}
|
|
60
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
61
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
62
|
+
}
|
|
63
|
+
const ctx = {
|
|
64
|
+
commonApiUrl,
|
|
65
|
+
delegatedToken: mint.token,
|
|
66
|
+
fetchImpl: options.fetchImpl,
|
|
67
|
+
};
|
|
68
|
+
const path = options.stateFilter
|
|
69
|
+
? `/workspaces/${mint.workspaceId}/runs?executionState=${options.stateFilter}`
|
|
70
|
+
: `/workspaces/${mint.workspaceId}/runs`;
|
|
71
|
+
const response = await authenticatedRequest(ctx, path, { method: 'GET' });
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const text = await safeReadText(response);
|
|
74
|
+
return fail('http', `work.list_runs failed: HTTP ${response.status} — ${text}`, {
|
|
75
|
+
status: response.status,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const json = (await response.json());
|
|
79
|
+
if (!Array.isArray(json.runs)) {
|
|
80
|
+
return fail('http', 'work.list_runs response missing `runs` array');
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
output: format === 'json'
|
|
85
|
+
? formatJson(json.runs)
|
|
86
|
+
: formatSummary(json.runs, mint.workspaceId, options.stateFilter),
|
|
87
|
+
runs: json.runs,
|
|
88
|
+
workspaceId: mint.workspaceId,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
92
|
+
/**
|
|
93
|
+
* Header + one row per run. Columns (auto-padded):
|
|
94
|
+
* planRunId planId #N state operator startedAt
|
|
95
|
+
* Empty list renders "(no runs)" instead of just a header.
|
|
96
|
+
*/
|
|
97
|
+
export function formatSummary(runs, workspaceId, stateFilter) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
const filterTag = stateFilter ? ` (filter: ${stateFilter})` : '';
|
|
100
|
+
lines.push(`Plan Runs in workspace ${workspaceId}${filterTag}: ${runs.length}`);
|
|
101
|
+
if (runs.length === 0) {
|
|
102
|
+
lines.push(' (no runs)');
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
const idCol = Math.max(9, ...runs.map((r) => r.planRunId.length));
|
|
106
|
+
const planCol = Math.max(6, ...runs.map((r) => r.planId.length));
|
|
107
|
+
const stateCol = Math.max(5, ...runs.map((r) => r.executionState.length));
|
|
108
|
+
const operatorCol = Math.max(8, ...runs.map((r) => (r.operatorAgentId ?? '(unbound)').length));
|
|
109
|
+
const header = ` ${'planRunId'.padEnd(idCol)} ${'planId'.padEnd(planCol)} run# ${'state'.padEnd(stateCol)} ${'operator'.padEnd(operatorCol)} startedAt`;
|
|
110
|
+
lines.push(header);
|
|
111
|
+
for (const r of runs) {
|
|
112
|
+
const id = r.planRunId.padEnd(idCol);
|
|
113
|
+
const plan = r.planId.padEnd(planCol);
|
|
114
|
+
const runNum = `#${r.runNumber}`.padEnd(5);
|
|
115
|
+
const state = r.executionState.padEnd(stateCol);
|
|
116
|
+
const operator = (r.operatorAgentId ?? '(unbound)').padEnd(operatorCol);
|
|
117
|
+
lines.push(` ${id} ${plan} ${runNum} ${state} ${operator} ${r.startedAt}`);
|
|
118
|
+
}
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
function formatJson(runs) {
|
|
122
|
+
return JSON.stringify(runs, null, 2);
|
|
123
|
+
}
|
|
124
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
125
|
+
function fail(kind, message, detail) {
|
|
126
|
+
return { ok: false, kind, message, detail };
|
|
127
|
+
}
|
|
128
|
+
function describeError(err) {
|
|
129
|
+
return err instanceof Error ? err.message : String(err);
|
|
130
|
+
}
|
|
131
|
+
async function safeReadText(response) {
|
|
132
|
+
try {
|
|
133
|
+
return await response.text();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return '<no body>';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function exitCodeForFailure(kind) {
|
|
140
|
+
switch (kind) {
|
|
141
|
+
case 'usage':
|
|
142
|
+
case 'auth':
|
|
143
|
+
case 'workspace_mismatch':
|
|
144
|
+
return 64;
|
|
145
|
+
case 'http':
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo run start <planId>` — bootstrap a Plan Run via work.start_run.
|
|
3
|
+
*
|
|
4
|
+
* Mints a session-bound delegated token (work.start_run scope) and
|
|
5
|
+
* POSTs to `/internal/work/workspaces/<wsId>/runs` with the planId
|
|
6
|
+
* and (optional) inputs. The route handler (common-api/src/routes/
|
|
7
|
+
* work.ts) binds `operatorAgentId: ctx.agentId` — so a Run started
|
|
8
|
+
* via this verb has `operatorAgentId === 'substrate-cli'` and only
|
|
9
|
+
* the substrate CLI can pause/resume/cancel it via MCP.
|
|
10
|
+
*
|
|
11
|
+
* Inputs: passed as a JSON object literal via `--inputs '<json>'`.
|
|
12
|
+
* If absent, defaults to `{}` (the route accepts that and lets Plan
|
|
13
|
+
* input validation reject if any inputs were declared as required).
|
|
14
|
+
*
|
|
15
|
+
* Errors map to exit codes via the CLI wrapper:
|
|
16
|
+
* - 0 = success
|
|
17
|
+
* - 1 = http (plan not found, plan not active, validation failure)
|
|
18
|
+
* - 64 = usage (bad planId, malformed --inputs JSON, missing env
|
|
19
|
+
* trio, --workspace mismatch)
|
|
20
|
+
*/
|
|
21
|
+
import { SUBSTRATE_CLI_RUN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
22
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
23
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
24
|
+
const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
25
|
+
export async function runRunStart(options) {
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
const format = options.outputFormat ?? 'summary';
|
|
28
|
+
if (!PLAN_ID_REGEX.test(options.planId)) {
|
|
29
|
+
return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
|
|
30
|
+
}
|
|
31
|
+
const auth = resolveSubstrateContext(env);
|
|
32
|
+
if (!auth.ok)
|
|
33
|
+
return fail('auth', auth.message);
|
|
34
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
35
|
+
let mint;
|
|
36
|
+
try {
|
|
37
|
+
mint = await mintSubstrateToken({
|
|
38
|
+
commonApiUrl,
|
|
39
|
+
userToken,
|
|
40
|
+
sessionId,
|
|
41
|
+
scopes: SUBSTRATE_CLI_RUN_SCOPES,
|
|
42
|
+
fetchImpl: options.fetchImpl,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err instanceof WorkClientError) {
|
|
47
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
48
|
+
}
|
|
49
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
50
|
+
}
|
|
51
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
52
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
53
|
+
}
|
|
54
|
+
const ctx = {
|
|
55
|
+
commonApiUrl,
|
|
56
|
+
delegatedToken: mint.token,
|
|
57
|
+
fetchImpl: options.fetchImpl,
|
|
58
|
+
};
|
|
59
|
+
const body = { planId: options.planId };
|
|
60
|
+
if (options.inputs !== undefined)
|
|
61
|
+
body.inputs = options.inputs;
|
|
62
|
+
const response = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/runs`, { method: 'POST', jsonBody: body });
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const text = await safeReadText(response);
|
|
65
|
+
return fail('http', `work.start_run failed: HTTP ${response.status} — ${text}`, { status: response.status });
|
|
66
|
+
}
|
|
67
|
+
const json = (await response.json());
|
|
68
|
+
if (typeof json.planRunId !== 'string' || !json.planRunId) {
|
|
69
|
+
return fail('http', 'work.start_run response missing planRunId');
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
output: format === 'json' ? formatJson(json) : formatSummary(json, options.planId),
|
|
74
|
+
response: json,
|
|
75
|
+
workspaceId: mint.workspaceId,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
79
|
+
export function formatSummary(response, planId) {
|
|
80
|
+
const operatorTag = response.operatorAgentId ? `operator=${response.operatorAgentId}` : 'operator=(unbound)';
|
|
81
|
+
return `OK: started run ${response.planRunId} for plan ${planId} (run #${response.runNumber}, executionState=${response.executionState}, ${operatorTag})`;
|
|
82
|
+
}
|
|
83
|
+
function formatJson(response) {
|
|
84
|
+
return JSON.stringify(response, null, 2);
|
|
85
|
+
}
|
|
86
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
87
|
+
function fail(kind, message, detail) {
|
|
88
|
+
return { ok: false, kind, message, detail };
|
|
89
|
+
}
|
|
90
|
+
function describeError(err) {
|
|
91
|
+
return err instanceof Error ? err.message : String(err);
|
|
92
|
+
}
|
|
93
|
+
async function safeReadText(response) {
|
|
94
|
+
try {
|
|
95
|
+
return await response.text();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return '<no body>';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function exitCodeForFailure(kind) {
|
|
102
|
+
switch (kind) {
|
|
103
|
+
case 'usage':
|
|
104
|
+
case 'auth':
|
|
105
|
+
case 'workspace_mismatch':
|
|
106
|
+
return 64;
|
|
107
|
+
case 'http':
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo run transfer <runId> --to <agentId>` — change a Run's bound
|
|
3
|
+
* Operator. The handoff verb that closes the loop on the lifecycle
|
|
4
|
+
* slice's operator-binding implication.
|
|
5
|
+
*
|
|
6
|
+
* Natural use case: `yolo run start <planId>` makes substrate-cli
|
|
7
|
+
* the Operator; `yolo run transfer <runId> --to claude` hands the
|
|
8
|
+
* Run off to claude/codex for actual execution. The route's R4
|
|
9
|
+
* matrix permits self-transfer (current Operator → new agent),
|
|
10
|
+
* claim-user-driven (taking over a Run with operatorAgentId=null),
|
|
11
|
+
* and force-transfer of an unresponsive Operator.
|
|
12
|
+
*
|
|
13
|
+
* Target validation: the route enforces `isValidOperatorTarget` —
|
|
14
|
+
* target must be in OPERATOR_TIER_AGENT_IDS (claude, codex) or
|
|
15
|
+
* null (user-driven). Substrate-cli is NOT Operator-tier, so
|
|
16
|
+
* `--to substrate-cli` always fails at the route layer with 403
|
|
17
|
+
* NOT_AUTHORIZED + reason=invalid_transfer_target.
|
|
18
|
+
*
|
|
19
|
+
* Errors map to exit codes via the CLI wrapper:
|
|
20
|
+
* - 0 = success
|
|
21
|
+
* - 1 = http (404, 403 NOT_AUTHORIZED, 409 INVALID_STATE, 5xx)
|
|
22
|
+
* - 64 = usage (bad runId, missing env trio, --workspace mismatch,
|
|
23
|
+
* --to + --user-driven mutex violation, missing target)
|
|
24
|
+
*/
|
|
25
|
+
import { SUBSTRATE_CLI_RUN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
26
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
27
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
28
|
+
const PLAN_RUN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,191}$/;
|
|
29
|
+
const AGENT_ID_MAX_LEN = 128;
|
|
30
|
+
export async function runRunTransfer(options) {
|
|
31
|
+
const env = options.env ?? process.env;
|
|
32
|
+
const format = options.outputFormat ?? 'summary';
|
|
33
|
+
if (!PLAN_RUN_ID_REGEX.test(options.planRunId)) {
|
|
34
|
+
return fail('usage', `invalid planRunId '${options.planRunId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,191}$/`);
|
|
35
|
+
}
|
|
36
|
+
// Target must be a non-empty string ≤128 chars or explicit null.
|
|
37
|
+
// Mirrors the route's pre-store check so we fail fast without a
|
|
38
|
+
// network round-trip.
|
|
39
|
+
if (options.newOperatorAgentId !== null &&
|
|
40
|
+
(typeof options.newOperatorAgentId !== 'string' ||
|
|
41
|
+
options.newOperatorAgentId.length === 0 ||
|
|
42
|
+
options.newOperatorAgentId.length > AGENT_ID_MAX_LEN)) {
|
|
43
|
+
return fail('usage', `newOperatorAgentId must be a non-empty string ≤${AGENT_ID_MAX_LEN} chars or null`);
|
|
44
|
+
}
|
|
45
|
+
const auth = resolveSubstrateContext(env);
|
|
46
|
+
if (!auth.ok)
|
|
47
|
+
return fail('auth', auth.message);
|
|
48
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
49
|
+
let mint;
|
|
50
|
+
try {
|
|
51
|
+
mint = await mintSubstrateToken({
|
|
52
|
+
commonApiUrl,
|
|
53
|
+
userToken,
|
|
54
|
+
sessionId,
|
|
55
|
+
scopes: SUBSTRATE_CLI_RUN_SCOPES,
|
|
56
|
+
fetchImpl: options.fetchImpl,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
if (err instanceof WorkClientError) {
|
|
61
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
62
|
+
}
|
|
63
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
64
|
+
}
|
|
65
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
66
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
67
|
+
}
|
|
68
|
+
const ctx = {
|
|
69
|
+
commonApiUrl,
|
|
70
|
+
delegatedToken: mint.token,
|
|
71
|
+
fetchImpl: options.fetchImpl,
|
|
72
|
+
};
|
|
73
|
+
const response = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/runs/${options.planRunId}/operator/transfer`, { method: 'POST', jsonBody: { newOperatorAgentId: options.newOperatorAgentId } });
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const text = await safeReadText(response);
|
|
76
|
+
return fail('http', `work.transfer_run_operator failed: HTTP ${response.status} — ${text}`, {
|
|
77
|
+
status: response.status,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const json = (await response.json());
|
|
81
|
+
if (typeof json.planRunId !== 'string') {
|
|
82
|
+
return fail('http', 'work.transfer_run_operator response missing planRunId');
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
output: format === 'json' ? formatJson(json) : formatSummary(json),
|
|
87
|
+
response: json,
|
|
88
|
+
workspaceId: mint.workspaceId,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
92
|
+
export function formatSummary(response) {
|
|
93
|
+
const target = response.operatorAgentId === null
|
|
94
|
+
? 'user-driven (primaryOperator=null)'
|
|
95
|
+
: response.operatorAgentId;
|
|
96
|
+
// operatorCount surfaces the size of the additive operators[];
|
|
97
|
+
// the legacy operatorHistoryLength was removed in item 16.
|
|
98
|
+
const count = response.operatorCount ?? 0;
|
|
99
|
+
return `OK: transferred run ${response.planRunId} primary to ${target} (operatorCount=${count})`;
|
|
100
|
+
}
|
|
101
|
+
function formatJson(response) {
|
|
102
|
+
return JSON.stringify(response, null, 2);
|
|
103
|
+
}
|
|
104
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
105
|
+
function fail(kind, message, detail) {
|
|
106
|
+
return { ok: false, kind, message, detail };
|
|
107
|
+
}
|
|
108
|
+
function describeError(err) {
|
|
109
|
+
return err instanceof Error ? err.message : String(err);
|
|
110
|
+
}
|
|
111
|
+
async function safeReadText(response) {
|
|
112
|
+
try {
|
|
113
|
+
return await response.text();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return '<no body>';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function exitCodeForFailure(kind) {
|
|
120
|
+
switch (kind) {
|
|
121
|
+
case 'usage':
|
|
122
|
+
case 'auth':
|
|
123
|
+
case 'workspace_mismatch':
|
|
124
|
+
return 64;
|
|
125
|
+
case 'http':
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo serve <dir>` — a minimal, dependency-free static file server.
|
|
3
|
+
*
|
|
4
|
+
* Purpose (BAKE_OFF / decision-preview Gap 2a): a `mode:workstream`
|
|
5
|
+
* candidate that produces a STATIC site (index.html + assets, no dev
|
|
6
|
+
* server) has nothing to preview. The substrate auto-spawns a preview tile
|
|
7
|
+
* whose command is `yolo serve <lane-worktree> --port $PORT` so the operator
|
|
8
|
+
* can SEE each bake-off variant before picking a winner — without the agent
|
|
9
|
+
* having to ship a server or an operator hand-rolling `python3 -m http.server`.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately built on Node's stdlib only (http/fs/path) so it has zero
|
|
12
|
+
* install cost and starts instantly in any sandbox. Long-running: it binds
|
|
13
|
+
* the port and runs until the process is killed (the preview-manager owns
|
|
14
|
+
* the lifecycle), so the command never "completes".
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* yolo serve <dir> [--port N] [--host H] [--spa] [--no-cache]
|
|
18
|
+
* <dir> directory to serve (required)
|
|
19
|
+
* --port N port (default: $PORT, else 3000)
|
|
20
|
+
* --host H bind host (default: 0.0.0.0 — reachable by preview-manager)
|
|
21
|
+
* --spa serve index.html for unmatched routes (single-page apps)
|
|
22
|
+
* --no-cache send no-store (default; previews should always be fresh)
|
|
23
|
+
*/
|
|
24
|
+
import * as http from 'http';
|
|
25
|
+
import * as fs from 'fs';
|
|
26
|
+
import * as path from 'path';
|
|
27
|
+
const MIME = {
|
|
28
|
+
'.html': 'text/html; charset=utf-8',
|
|
29
|
+
'.htm': 'text/html; charset=utf-8',
|
|
30
|
+
'.css': 'text/css; charset=utf-8',
|
|
31
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
32
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
33
|
+
'.json': 'application/json; charset=utf-8',
|
|
34
|
+
'.svg': 'image/svg+xml',
|
|
35
|
+
'.png': 'image/png',
|
|
36
|
+
'.jpg': 'image/jpeg',
|
|
37
|
+
'.jpeg': 'image/jpeg',
|
|
38
|
+
'.gif': 'image/gif',
|
|
39
|
+
'.webp': 'image/webp',
|
|
40
|
+
'.ico': 'image/x-icon',
|
|
41
|
+
'.woff': 'font/woff',
|
|
42
|
+
'.woff2': 'font/woff2',
|
|
43
|
+
'.ttf': 'font/ttf',
|
|
44
|
+
'.map': 'application/json; charset=utf-8',
|
|
45
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
46
|
+
'.wasm': 'application/wasm',
|
|
47
|
+
'.mp4': 'video/mp4',
|
|
48
|
+
'.webm': 'video/webm',
|
|
49
|
+
'.mp3': 'audio/mpeg',
|
|
50
|
+
};
|
|
51
|
+
/** Parse `yolo serve` args. Exported for tests. Returns the options or a
|
|
52
|
+
* usage error string. */
|
|
53
|
+
export function parseServeArgs(args) {
|
|
54
|
+
let dir;
|
|
55
|
+
let port;
|
|
56
|
+
let host = '0.0.0.0';
|
|
57
|
+
let spa = false;
|
|
58
|
+
let noCache = true;
|
|
59
|
+
for (let i = 0; i < args.length; i++) {
|
|
60
|
+
const a = args[i];
|
|
61
|
+
if (a === '--port' || a === '-p') {
|
|
62
|
+
const v = args[++i];
|
|
63
|
+
const n = Number(v);
|
|
64
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
65
|
+
return { ok: false, error: `invalid --port '${v}'` };
|
|
66
|
+
port = n;
|
|
67
|
+
}
|
|
68
|
+
else if (a.startsWith('--port=')) {
|
|
69
|
+
const n = Number(a.slice('--port='.length));
|
|
70
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
71
|
+
return { ok: false, error: `invalid --port` };
|
|
72
|
+
port = n;
|
|
73
|
+
}
|
|
74
|
+
else if (a === '--host') {
|
|
75
|
+
host = args[++i] ?? host;
|
|
76
|
+
}
|
|
77
|
+
else if (a.startsWith('--host=')) {
|
|
78
|
+
host = a.slice('--host='.length);
|
|
79
|
+
}
|
|
80
|
+
else if (a === '--spa') {
|
|
81
|
+
spa = true;
|
|
82
|
+
}
|
|
83
|
+
else if (a === '--cache') {
|
|
84
|
+
noCache = false;
|
|
85
|
+
}
|
|
86
|
+
else if (a === '--no-cache') {
|
|
87
|
+
noCache = true;
|
|
88
|
+
}
|
|
89
|
+
else if (a.startsWith('-')) {
|
|
90
|
+
return { ok: false, error: `unknown flag '${a}'` };
|
|
91
|
+
}
|
|
92
|
+
else if (dir === undefined) {
|
|
93
|
+
dir = a;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
return { ok: false, error: `unexpected argument '${a}'` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!dir)
|
|
100
|
+
return { ok: false, error: 'serve requires a <dir> argument' };
|
|
101
|
+
// $PORT (preview-manager injects it) is the fallback before the 3000 default.
|
|
102
|
+
if (port === undefined) {
|
|
103
|
+
const envPort = Number(process.env.PORT);
|
|
104
|
+
port = Number.isInteger(envPort) && envPort >= 1 && envPort <= 65535 ? envPort : 3000;
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, opts: { dir: path.resolve(dir), port, host, spa, noCache } };
|
|
107
|
+
}
|
|
108
|
+
/** Resolve a request URL path to an on-disk file path, GUARDING against
|
|
109
|
+
* traversal outside `root`. Returns null when the resolved path escapes
|
|
110
|
+
* root. Exported for tests. */
|
|
111
|
+
export function resolveRequestPath(root, urlPath) {
|
|
112
|
+
// Strip query/hash, decode, normalize.
|
|
113
|
+
let p = urlPath.split('?')[0].split('#')[0];
|
|
114
|
+
try {
|
|
115
|
+
p = decodeURIComponent(p);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null; // malformed percent-encoding
|
|
119
|
+
}
|
|
120
|
+
// A decoded NUL byte (e.g. `/%00`) passes decodeURIComponent but makes the
|
|
121
|
+
// downstream fs.* calls THROW SYNCHRONOUSLY (ERR_INVALID_ARG_VALUE),
|
|
122
|
+
// crashing the server instead of returning a 4xx. Reject it here.
|
|
123
|
+
if (p.includes('\0'))
|
|
124
|
+
return null;
|
|
125
|
+
// Join + normalize, then confirm the result is still within root.
|
|
126
|
+
const resolved = path.resolve(root, '.' + (p.startsWith('/') ? p : '/' + p));
|
|
127
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep))
|
|
128
|
+
return null;
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
export function createServeHandler(opts) {
|
|
132
|
+
// The real (symlink-resolved) served root, computed once. Per-request
|
|
133
|
+
// realpath checks confirm targets stay within THIS. Falls back to the
|
|
134
|
+
// lexical dir if the root itself can't be realpath'd (shouldn't happen —
|
|
135
|
+
// runServeCmd verified it's a directory first).
|
|
136
|
+
let realRoot = opts.dir;
|
|
137
|
+
try {
|
|
138
|
+
realRoot = fs.realpathSync(opts.dir);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* keep lexical opts.dir */
|
|
142
|
+
}
|
|
143
|
+
return (req, res) => {
|
|
144
|
+
const send = (status, body, contentType) => {
|
|
145
|
+
res.writeHead(status, {
|
|
146
|
+
'Content-Type': contentType,
|
|
147
|
+
...(opts.noCache ? { 'Cache-Control': 'no-store, max-age=0' } : {}),
|
|
148
|
+
});
|
|
149
|
+
res.end(body);
|
|
150
|
+
};
|
|
151
|
+
const resolved = resolveRequestPath(opts.dir, req.url ?? '/');
|
|
152
|
+
if (resolved === null) {
|
|
153
|
+
send(400, 'Bad Request', 'text/plain; charset=utf-8');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const serveFile = (filePath) => {
|
|
157
|
+
// Resolve symlinks and RE-CHECK containment: the lexical guard in
|
|
158
|
+
// resolveRequestPath can't catch a symlink INSIDE root that points
|
|
159
|
+
// outside it. realpath the target and confirm its real path is still
|
|
160
|
+
// within realRoot before reading — blocks symlink escapes while still
|
|
161
|
+
// allowing symlinks that stay inside the served tree.
|
|
162
|
+
fs.realpath(filePath, (rpErr, realPath) => {
|
|
163
|
+
if (rpErr) {
|
|
164
|
+
// Missing / broken-symlink → SPA fallback to root index.html, else 404.
|
|
165
|
+
if (opts.spa) {
|
|
166
|
+
const indexPath = path.join(opts.dir, 'index.html');
|
|
167
|
+
if (filePath !== indexPath) {
|
|
168
|
+
serveFile(indexPath);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
send(404, 'Not Found', 'text/plain; charset=utf-8');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
|
|
176
|
+
send(403, 'Forbidden', 'text/plain; charset=utf-8'); // symlink escaped root
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
fs.readFile(realPath, (err, data) => {
|
|
180
|
+
if (err) {
|
|
181
|
+
send(404, 'Not Found', 'text/plain; charset=utf-8');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
send(200, data, MIME[path.extname(realPath).toLowerCase()] ?? 'application/octet-stream');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
fs.stat(resolved, (err, stat) => {
|
|
189
|
+
if (err) {
|
|
190
|
+
serveFile(resolved); // delegate to serveFile's 404 / SPA handling
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Directory → its index.html.
|
|
194
|
+
if (stat.isDirectory()) {
|
|
195
|
+
serveFile(path.join(resolved, 'index.html'));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
serveFile(resolved);
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** Start the static server. Resolves only on a fatal listen error;
|
|
203
|
+
* otherwise runs until the process is killed (long-running). */
|
|
204
|
+
export async function runServeCmd(args) {
|
|
205
|
+
const parsed = parseServeArgs(args);
|
|
206
|
+
if (!parsed.ok) {
|
|
207
|
+
process.stderr.write(`yolo serve: ${parsed.error}\n`);
|
|
208
|
+
process.stderr.write('Usage: yolo serve <dir> [--port N] [--host H] [--spa] [--no-cache]\n');
|
|
209
|
+
return 64; // EX_USAGE
|
|
210
|
+
}
|
|
211
|
+
const { opts } = parsed;
|
|
212
|
+
if (!fs.existsSync(opts.dir) || !fs.statSync(opts.dir).isDirectory()) {
|
|
213
|
+
process.stderr.write(`yolo serve: not a directory: ${opts.dir}\n`);
|
|
214
|
+
return 66; // EX_NOINPUT
|
|
215
|
+
}
|
|
216
|
+
const server = http.createServer(createServeHandler(opts));
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
server.on('error', (err) => {
|
|
219
|
+
process.stderr.write(`yolo serve: ${err.code === 'EADDRINUSE' ? `port ${opts.port} is already in use` : err.message}\n`);
|
|
220
|
+
resolve(70); // EX_SOFTWARE
|
|
221
|
+
});
|
|
222
|
+
server.listen(opts.port, opts.host, () => {
|
|
223
|
+
process.stdout.write(`yolo serve: serving ${opts.dir} on http://${opts.host}:${opts.port}\n`);
|
|
224
|
+
// Intentionally never resolve — the server runs until killed.
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|