@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/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @yololabs/yolo-cli — YOLO Studio substrate CLI
|
|
2
|
+
|
|
3
|
+
The `yolo` binary owns substrate-tooling subcommands (Plan import/export,
|
|
4
|
+
future workspace and artifact primitives). Distinct from:
|
|
5
|
+
|
|
6
|
+
- **`yolo-code`** — YOLO Studio's built-in coding agent CLI (separate package).
|
|
7
|
+
- **`yolo-router`** — the LLM gateway client (separate package).
|
|
8
|
+
|
|
9
|
+
## Status
|
|
10
|
+
|
|
11
|
+
Phase 8a Group 9 scaffold. v1 ships only `yolo --version` and
|
|
12
|
+
`yolo context` (resolves and prints the container ambient session
|
|
13
|
+
context). Plan import/export lands in Phase 8c.
|
|
14
|
+
|
|
15
|
+
## Usage (in-container)
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
yolo --version
|
|
19
|
+
yolo context
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Outside a Studio container the CLI exits with `session-required`. A future
|
|
23
|
+
external-login flow is planned but out of scope for Phase 8.
|
|
24
|
+
|
|
25
|
+
## Auth contract
|
|
26
|
+
|
|
27
|
+
Requires:
|
|
28
|
+
|
|
29
|
+
- `SESSION_ID` (load-bearing — workspace derives from the session record)
|
|
30
|
+
- `YOLO_COMMON_API_URL` (or `YOLO_API_URL`)
|
|
31
|
+
- A credential — resolved by precedence (AUTH_AND_ONBOARDING Slice 0):
|
|
32
|
+
1. `~/.config/yolo/token` — the rotated **user access JWT**, rewritten
|
|
33
|
+
every ~10 min by container-api's token-refresh service. Preferred;
|
|
34
|
+
reading the file (not the env var) avoids the stale-shell problem.
|
|
35
|
+
2. `YOLO_API_TOKEN` env — the pod-injected user JWT (≤24h).
|
|
36
|
+
3. `INTERNAL_API_KEY` env — service master-key fallback, kept for
|
|
37
|
+
lane-runner / service callers. **No longer required in user shells**
|
|
38
|
+
and intentionally excluded from the sandbox env.
|
|
39
|
+
|
|
40
|
+
The CLI mints a short-lived delegated MCP token via the session-bound
|
|
41
|
+
endpoint (`POST /internal/mcp/tokens`) with `agentId: 'substrate-cli'`
|
|
42
|
+
and a capped scope set, authenticating with the user JWT
|
|
43
|
+
(`Authorization: Bearer`) when available, falling back to
|
|
44
|
+
`X-Internal-Auth` otherwise. It then calls `/internal/work/*` REST
|
|
45
|
+
routes with the delegated bearer (which is the capability — the
|
|
46
|
+
service header is no longer required by `requireMcpAuth`).
|
|
47
|
+
|
|
48
|
+
## Local build
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm install
|
|
52
|
+
npm run build
|
|
53
|
+
node dist/cli.js --version
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Standalone package — not a workspace member. Container install is an
|
|
57
|
+
explicit `COPY` + `npm install -g` block in `containers/sandboxes/yolo-main/Dockerfile`.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo artifact get <key>` — fetch a single artifact version.
|
|
3
|
+
*
|
|
4
|
+
* Returns the latest version by default; `--version <n>` pins to a
|
|
5
|
+
* specific version. Default summary renders metadata only (key,
|
|
6
|
+
* version, type, producer, fingerprint, contentLength, createdAt).
|
|
7
|
+
* `--content` flag emits ONLY the artifact content body to stdout
|
|
8
|
+
* (suitable for piping). `--json` emits the full record including
|
|
9
|
+
* content.
|
|
10
|
+
*
|
|
11
|
+
* Errors map to exit codes via the CLI wrapper:
|
|
12
|
+
* - 0 = success
|
|
13
|
+
* - 1 = http (artifact not found, etc.)
|
|
14
|
+
* - 64 = usage (bad key, bad --version, missing env trio,
|
|
15
|
+
* --workspace mismatch, --content + --json conflict)
|
|
16
|
+
*/
|
|
17
|
+
import { SUBSTRATE_CLI_ARTIFACT_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
18
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
19
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
20
|
+
const ARTIFACT_KEY_REGEX = /^[a-z0-9][a-z0-9/_.\-]{0,127}$/;
|
|
21
|
+
export async function runArtifactGet(options) {
|
|
22
|
+
const env = options.env ?? process.env;
|
|
23
|
+
const format = options.outputFormat ?? 'summary';
|
|
24
|
+
if (!ARTIFACT_KEY_REGEX.test(options.key)) {
|
|
25
|
+
return fail('usage', `invalid artifact key '${options.key}': must match /^[a-z0-9][a-z0-9/_.\\-]{0,127}$/`);
|
|
26
|
+
}
|
|
27
|
+
if (options.version !== undefined) {
|
|
28
|
+
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
29
|
+
return fail('usage', `invalid --version '${options.version}': must be a positive integer`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const auth = resolveSubstrateContext(env);
|
|
33
|
+
if (!auth.ok)
|
|
34
|
+
return fail('auth', auth.message);
|
|
35
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
36
|
+
let mint;
|
|
37
|
+
try {
|
|
38
|
+
mint = await mintSubstrateToken({
|
|
39
|
+
commonApiUrl,
|
|
40
|
+
userToken,
|
|
41
|
+
sessionId,
|
|
42
|
+
scopes: SUBSTRATE_CLI_ARTIFACT_SCOPES,
|
|
43
|
+
fetchImpl: options.fetchImpl,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err instanceof WorkClientError) {
|
|
48
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
49
|
+
}
|
|
50
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
51
|
+
}
|
|
52
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
53
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
54
|
+
}
|
|
55
|
+
const ctx = {
|
|
56
|
+
commonApiUrl,
|
|
57
|
+
delegatedToken: mint.token,
|
|
58
|
+
fetchImpl: options.fetchImpl,
|
|
59
|
+
};
|
|
60
|
+
const path = options.version !== undefined
|
|
61
|
+
? `/workspaces/${mint.workspaceId}/artifacts/${encodeURIComponent(options.key)}?version=${options.version}`
|
|
62
|
+
: `/workspaces/${mint.workspaceId}/artifacts/${encodeURIComponent(options.key)}`;
|
|
63
|
+
const response = await authenticatedRequest(ctx, path, { method: 'GET' });
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const text = await safeReadText(response);
|
|
66
|
+
return fail('http', `work.get_artifact failed: HTTP ${response.status} — ${text}`, { status: response.status });
|
|
67
|
+
}
|
|
68
|
+
const artifact = (await response.json());
|
|
69
|
+
if (typeof artifact.key !== 'string' || typeof artifact.version !== 'number') {
|
|
70
|
+
return fail('http', 'work.get_artifact response missing required fields');
|
|
71
|
+
}
|
|
72
|
+
let output;
|
|
73
|
+
if (format === 'json')
|
|
74
|
+
output = formatJson(artifact);
|
|
75
|
+
else if (format === 'content')
|
|
76
|
+
output = artifact.content;
|
|
77
|
+
else
|
|
78
|
+
output = formatSummary(artifact, mint.workspaceId);
|
|
79
|
+
return { ok: true, output, artifact, workspaceId: mint.workspaceId };
|
|
80
|
+
}
|
|
81
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Header with metadata — no content body. The producer block tells
|
|
84
|
+
* the operator who emitted the artifact (agent + tile) and provenance
|
|
85
|
+
* fingerprint for cross-run identity.
|
|
86
|
+
*/
|
|
87
|
+
export function formatSummary(artifact, workspaceId) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
lines.push(`Artifact ${artifact.key} v${artifact.version} (workspace ${workspaceId})`);
|
|
90
|
+
lines.push(` type: ${artifact.artifactType}`);
|
|
91
|
+
lines.push(` contentLength: ${artifact.contentLength}`);
|
|
92
|
+
lines.push(` producedBy: ${artifact.producedByAgentId}${artifact.producedByTileId ? ` (tile ${artifact.producedByTileId})` : ''}`);
|
|
93
|
+
if (artifact.producerType)
|
|
94
|
+
lines.push(` producerType: ${artifact.producerType}`);
|
|
95
|
+
if (artifact.producerId)
|
|
96
|
+
lines.push(` producerId: ${artifact.producerId}`);
|
|
97
|
+
if (artifact.fingerprint)
|
|
98
|
+
lines.push(` fingerprint: ${artifact.fingerprint}`);
|
|
99
|
+
if (artifact.refPath)
|
|
100
|
+
lines.push(` refPath: ${artifact.refPath}`);
|
|
101
|
+
lines.push(` createdAt: ${artifact.createdAt}`);
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
function formatJson(artifact) {
|
|
105
|
+
return JSON.stringify(artifact, null, 2);
|
|
106
|
+
}
|
|
107
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
108
|
+
function fail(kind, message, detail) {
|
|
109
|
+
return { ok: false, kind, message, detail };
|
|
110
|
+
}
|
|
111
|
+
function describeError(err) {
|
|
112
|
+
return err instanceof Error ? err.message : String(err);
|
|
113
|
+
}
|
|
114
|
+
async function safeReadText(response) {
|
|
115
|
+
try {
|
|
116
|
+
return await response.text();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return '<no body>';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function exitCodeForFailure(kind) {
|
|
123
|
+
switch (kind) {
|
|
124
|
+
case 'usage':
|
|
125
|
+
case 'auth':
|
|
126
|
+
case 'workspace_mismatch':
|
|
127
|
+
return 64;
|
|
128
|
+
case 'http':
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo artifact list` — enumerate workspace artifacts (newest-first).
|
|
3
|
+
*
|
|
4
|
+
* Default summary is a table per artifact: key, latest version,
|
|
5
|
+
* artifactType, latestTimestamp. `--prefix <prefix>` server-side
|
|
6
|
+
* filters by key prefix; `--limit <n>` caps result count (1-500,
|
|
7
|
+
* default 100). `--json` emits the raw `artifacts[]` array.
|
|
8
|
+
*
|
|
9
|
+
* Exit codes:
|
|
10
|
+
* - 0 = success (zero artifacts is also success — empty list,
|
|
11
|
+
* not an error)
|
|
12
|
+
* - 1 = http (server error, etc.)
|
|
13
|
+
* - 64 = usage (bad --limit, missing env, --workspace mismatch)
|
|
14
|
+
*/
|
|
15
|
+
import { SUBSTRATE_CLI_ARTIFACT_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
16
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
17
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
18
|
+
export async function runArtifactList(options) {
|
|
19
|
+
const env = options.env ?? process.env;
|
|
20
|
+
const format = options.outputFormat ?? 'summary';
|
|
21
|
+
if (options.limit !== undefined) {
|
|
22
|
+
if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 500) {
|
|
23
|
+
return fail('usage', `invalid --limit '${options.limit}': must be an integer between 1 and 500`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const auth = resolveSubstrateContext(env);
|
|
27
|
+
if (!auth.ok)
|
|
28
|
+
return fail('auth', auth.message);
|
|
29
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
30
|
+
let mint;
|
|
31
|
+
try {
|
|
32
|
+
mint = await mintSubstrateToken({
|
|
33
|
+
commonApiUrl,
|
|
34
|
+
userToken,
|
|
35
|
+
sessionId,
|
|
36
|
+
scopes: SUBSTRATE_CLI_ARTIFACT_SCOPES,
|
|
37
|
+
fetchImpl: options.fetchImpl,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err instanceof WorkClientError) {
|
|
42
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
43
|
+
}
|
|
44
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
45
|
+
}
|
|
46
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
47
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
48
|
+
}
|
|
49
|
+
const ctx = {
|
|
50
|
+
commonApiUrl,
|
|
51
|
+
delegatedToken: mint.token,
|
|
52
|
+
fetchImpl: options.fetchImpl,
|
|
53
|
+
};
|
|
54
|
+
const params = [];
|
|
55
|
+
if (options.prefix)
|
|
56
|
+
params.push(`prefix=${encodeURIComponent(options.prefix)}`);
|
|
57
|
+
if (options.limit !== undefined)
|
|
58
|
+
params.push(`limit=${options.limit}`);
|
|
59
|
+
const query = params.length > 0 ? `?${params.join('&')}` : '';
|
|
60
|
+
const path = `/workspaces/${mint.workspaceId}/artifacts${query}`;
|
|
61
|
+
const response = await authenticatedRequest(ctx, path, { method: 'GET' });
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const text = await safeReadText(response);
|
|
64
|
+
return fail('http', `work.list_artifacts failed: HTTP ${response.status} — ${text}`, {
|
|
65
|
+
status: response.status,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const json = (await response.json());
|
|
69
|
+
if (!Array.isArray(json.artifacts)) {
|
|
70
|
+
return fail('http', 'work.list_artifacts response missing `artifacts` array');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
output: format === 'json'
|
|
75
|
+
? formatJson(json.artifacts)
|
|
76
|
+
: formatSummary(json.artifacts, mint.workspaceId, options.prefix),
|
|
77
|
+
artifacts: json.artifacts,
|
|
78
|
+
workspaceId: mint.workspaceId,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Header + table row per artifact. Columns auto-pad to longest key
|
|
84
|
+
* and artifactType. Empty workspace renders "(no artifacts)" rather
|
|
85
|
+
* than just a header.
|
|
86
|
+
*/
|
|
87
|
+
export function formatSummary(artifacts, workspaceId, prefix) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
const filterTag = prefix ? ` (prefix: ${prefix})` : '';
|
|
90
|
+
lines.push(`Artifacts in workspace ${workspaceId}${filterTag}: ${artifacts.length}`);
|
|
91
|
+
if (artifacts.length === 0) {
|
|
92
|
+
lines.push(' (no artifacts)');
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
const keyCol = Math.max(3, ...artifacts.map((a) => a.key.length));
|
|
96
|
+
const typeCol = Math.max(4, ...artifacts.map((a) => a.artifactType.length));
|
|
97
|
+
lines.push(` ${'key'.padEnd(keyCol)} v# ${'type'.padEnd(typeCol)} latestTimestamp`);
|
|
98
|
+
for (const a of artifacts) {
|
|
99
|
+
const key = a.key.padEnd(keyCol);
|
|
100
|
+
const v = `v${a.latestVersion}`.padEnd(5);
|
|
101
|
+
const type = a.artifactType.padEnd(typeCol);
|
|
102
|
+
lines.push(` ${key} ${v} ${type} ${a.latestTimestamp}`);
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
function formatJson(artifacts) {
|
|
107
|
+
return JSON.stringify(artifacts, null, 2);
|
|
108
|
+
}
|
|
109
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
110
|
+
function fail(kind, message, detail) {
|
|
111
|
+
return { ok: false, kind, message, detail };
|
|
112
|
+
}
|
|
113
|
+
function describeError(err) {
|
|
114
|
+
return err instanceof Error ? err.message : String(err);
|
|
115
|
+
}
|
|
116
|
+
async function safeReadText(response) {
|
|
117
|
+
try {
|
|
118
|
+
return await response.text();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return '<no body>';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function exitCodeForFailure(kind) {
|
|
125
|
+
switch (kind) {
|
|
126
|
+
case 'usage':
|
|
127
|
+
case 'auth':
|
|
128
|
+
case 'workspace_mismatch':
|
|
129
|
+
return 64;
|
|
130
|
+
case 'http':
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrate CLI auth-context resolution (AUTH_AND_ONBOARDING Slice 0).
|
|
3
|
+
*
|
|
4
|
+
* The CLI used to accept `INTERNAL_API_KEY` — the service-to-service master
|
|
5
|
+
* key — from the shell env as a fallback. That master key should never be the
|
|
6
|
+
* credential a user-controlled shell wields, and the JWT path has fully
|
|
7
|
+
* replaced it (the mint + work routes accept the user JWT, ownership-checked).
|
|
8
|
+
* The fallback is now REMOVED: the CLI is user-JWT-only.
|
|
9
|
+
*
|
|
10
|
+
* Token precedence:
|
|
11
|
+
* 1. `~/.config/yolo/token` — the rotated user JWT, rewritten every
|
|
12
|
+
* ~10 min by container-api's yolo-token-refresh. Reading the FILE
|
|
13
|
+
* (not the `YOLO_API_TOKEN` env var) sidesteps the stale-shell
|
|
14
|
+
* problem: a long-lived shell holds whatever token was in scope at
|
|
15
|
+
* its own launch, but the file is always fresh.
|
|
16
|
+
* 2. `YOLO_API_TOKEN` env — the pod-injected user JWT (≤24h TTL).
|
|
17
|
+
*
|
|
18
|
+
* Auth requires SESSION_ID + a common-api URL + a user access JWT.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
export function resolveSubstrateContext(env, readFileImpl = defaultReadFile) {
|
|
24
|
+
const sessionId = env.SESSION_ID;
|
|
25
|
+
const commonApiUrl = env.YOLO_COMMON_API_URL || env.YOLO_API_URL;
|
|
26
|
+
if (!sessionId) {
|
|
27
|
+
return { ok: false, message: 'SESSION_ID env var is required (substrate CLI is container-only in v1)' };
|
|
28
|
+
}
|
|
29
|
+
if (!commonApiUrl) {
|
|
30
|
+
return { ok: false, message: 'YOLO_COMMON_API_URL (or YOLO_API_URL) env var is required' };
|
|
31
|
+
}
|
|
32
|
+
const userToken = resolveUserToken(env, readFileImpl);
|
|
33
|
+
if (!userToken) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
message: 'no user token available: expected a user access JWT in ~/.config/yolo/token or the YOLO_API_TOKEN env var. ' +
|
|
37
|
+
'The substrate CLI is user-JWT-only — the INTERNAL_API_KEY fallback was removed.',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, context: { sessionId, commonApiUrl, userToken } };
|
|
41
|
+
}
|
|
42
|
+
export function resolveUserToken(env, readFileImpl = defaultReadFile) {
|
|
43
|
+
const home = env.HOME || os.homedir();
|
|
44
|
+
const tokenFile = path.join(home, '.config', 'yolo', 'token');
|
|
45
|
+
const fromFile = readFileImpl(tokenFile);
|
|
46
|
+
if (fromFile && fromFile.trim())
|
|
47
|
+
return fromFile.trim();
|
|
48
|
+
const fromEnv = env.YOLO_API_TOKEN;
|
|
49
|
+
if (fromEnv && fromEnv.trim())
|
|
50
|
+
return fromEnv.trim();
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
export function defaultReadFile(filePath) {
|
|
54
|
+
try {
|
|
55
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-file canonicalizer (Phase 8b).
|
|
3
|
+
*
|
|
4
|
+
* Pure function: a parsed plan-frontmatter object → canonical YAML
|
|
5
|
+
* string. The canonical form is what `import` writes to the file
|
|
6
|
+
* after parsing/normalizing user input AND what `export` emits when
|
|
7
|
+
* snapshotting the live DB plan back to disk. Both paths share this
|
|
8
|
+
* one engine so a round-trip through import → export produces a
|
|
9
|
+
* byte-identical file.
|
|
10
|
+
*
|
|
11
|
+
* Round 5 stance #7 + Round 2 stance answers locked these rules:
|
|
12
|
+
* - Stable key order at the schema-defined level (planId, name,
|
|
13
|
+
* state, …, steps[]). Top-level field order is fixed.
|
|
14
|
+
* - Step-level + gate-level + input-level + declared-output-level +
|
|
15
|
+
* template-level orders are also schema-defined.
|
|
16
|
+
* - Nested arbitrary-keyed objects (gate.config,
|
|
17
|
+
* integrationPolicy, template.metadata) sort recursively
|
|
18
|
+
* lexicographically. The schema says nothing about their key
|
|
19
|
+
* order, so alphabetical is the only stable rule.
|
|
20
|
+
* - Unknown top-level keys (forward-compat) trail the known schema
|
|
21
|
+
* fields in lexicographic order.
|
|
22
|
+
* - Output uses `\n` line endings, single trailing newline, 2-space
|
|
23
|
+
* indent, plain scalars where unambiguous (double-quoted when a
|
|
24
|
+
* string would otherwise parse as a non-string YAML value), and
|
|
25
|
+
* no boolean/numeric coercion at parse time (caller's
|
|
26
|
+
* responsibility — js-yaml's default load is happy to coerce, so
|
|
27
|
+
* callers should run their own validator that doesn't).
|
|
28
|
+
*
|
|
29
|
+
* NOT enforced here (caller's job):
|
|
30
|
+
* - Schema validation (frontmatter shape).
|
|
31
|
+
* - Stripping DB-only fields (`_id`, `workspaceId`, `version`,
|
|
32
|
+
* `latestRunId`, `nextRunNumber`, `createdAt`, `updatedAt`).
|
|
33
|
+
* - Verifying `planId` matches the file stem.
|
|
34
|
+
* - Asserting stepId uniqueness, gate `requires` resolves to
|
|
35
|
+
* siblings, etc.
|
|
36
|
+
*/
|
|
37
|
+
import yaml from 'js-yaml';
|
|
38
|
+
// ─── Schema-defined field orders ──────────────────────────────────────────
|
|
39
|
+
// NB: no `description` at the plan level. Per the Phase 8 design
|
|
40
|
+
// (`docs/_ORCHESTRATION_PHASE8_PLAN_FILES_DESIGN.md` §"body is
|
|
41
|
+
// Plan.description"), the markdown body IS `Plan.description` —
|
|
42
|
+
// frontmatter has no description field, by design, to avoid dual
|
|
43
|
+
// sources. Step- and input-level `description` fields are unrelated
|
|
44
|
+
// and remain in their respective `*_FIELD_ORDER`s below.
|
|
45
|
+
const PLAN_FIELD_ORDER = [
|
|
46
|
+
'planId',
|
|
47
|
+
'name',
|
|
48
|
+
'state',
|
|
49
|
+
'failurePolicy',
|
|
50
|
+
'autoRetryCap',
|
|
51
|
+
'autoRetryFallback',
|
|
52
|
+
'inputs',
|
|
53
|
+
'integrationPolicy',
|
|
54
|
+
'steps',
|
|
55
|
+
];
|
|
56
|
+
const STEP_FIELD_ORDER = [
|
|
57
|
+
'stepId',
|
|
58
|
+
'name',
|
|
59
|
+
'description',
|
|
60
|
+
'mode',
|
|
61
|
+
'runner',
|
|
62
|
+
'gates',
|
|
63
|
+
'declaredOutputs',
|
|
64
|
+
'failurePolicy',
|
|
65
|
+
'autoRetryCap',
|
|
66
|
+
'autoRetryFallback',
|
|
67
|
+
'template',
|
|
68
|
+
];
|
|
69
|
+
const GATE_FIELD_ORDER = ['gateId', 'type', 'config'];
|
|
70
|
+
const INPUT_FIELD_ORDER = ['name', 'required', 'default', 'description'];
|
|
71
|
+
const DECLARED_OUTPUT_FIELD_ORDER = ['name', 'artifactType', 'required'];
|
|
72
|
+
const TEMPLATE_FIELD_ORDER = ['todoContent', 'todoFile', 'branch', 'metadata'];
|
|
73
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
74
|
+
/**
|
|
75
|
+
* Reorder an object's keys: known schema keys first (in the given
|
|
76
|
+
* order), then any unknown keys lexicographically. Missing schema
|
|
77
|
+
* keys are skipped (we never emit `null` for unset optional fields).
|
|
78
|
+
*/
|
|
79
|
+
function reorderObject(obj, order) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const key of order) {
|
|
82
|
+
if (key in obj && obj[key] !== undefined) {
|
|
83
|
+
out[key] = obj[key];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const known = new Set(order);
|
|
87
|
+
const extras = Object.keys(obj)
|
|
88
|
+
.filter((k) => !known.has(k) && obj[k] !== undefined)
|
|
89
|
+
.sort();
|
|
90
|
+
for (const k of extras) {
|
|
91
|
+
out[k] = obj[k];
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Recursively sort the keys of arbitrary-keyed nested objects
|
|
97
|
+
* lexicographically. Arrays preserve their declared order; primitives
|
|
98
|
+
* pass through unchanged.
|
|
99
|
+
*
|
|
100
|
+
* Used for `gate.config`, `integrationPolicy`, `template.metadata`,
|
|
101
|
+
* and any future free-form nested map. The schema doesn't dictate
|
|
102
|
+
* key order for these, so alphabetical is the only deterministic
|
|
103
|
+
* choice.
|
|
104
|
+
*/
|
|
105
|
+
function sortNestedKeys(value) {
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
return value.map(sortNestedKeys);
|
|
108
|
+
}
|
|
109
|
+
if (value !== null && typeof value === 'object') {
|
|
110
|
+
const obj = value;
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const key of Object.keys(obj).sort()) {
|
|
113
|
+
out[key] = sortNestedKeys(obj[key]);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Reorder a parsed plan frontmatter to canonical shape. Pure — does
|
|
121
|
+
* not mutate the input.
|
|
122
|
+
*/
|
|
123
|
+
export function canonicalizeFrontmatterObject(plan) {
|
|
124
|
+
const ordered = reorderObject(plan, PLAN_FIELD_ORDER);
|
|
125
|
+
if (Array.isArray(ordered.inputs)) {
|
|
126
|
+
ordered.inputs = ordered.inputs.map((input) => reorderObject(input, INPUT_FIELD_ORDER));
|
|
127
|
+
}
|
|
128
|
+
if (ordered.integrationPolicy && typeof ordered.integrationPolicy === 'object') {
|
|
129
|
+
ordered.integrationPolicy = sortNestedKeys(ordered.integrationPolicy);
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(ordered.steps)) {
|
|
132
|
+
ordered.steps = ordered.steps.map((step) => {
|
|
133
|
+
const orderedStep = reorderObject(step, STEP_FIELD_ORDER);
|
|
134
|
+
if (Array.isArray(orderedStep.gates)) {
|
|
135
|
+
orderedStep.gates = orderedStep.gates.map((gate) => {
|
|
136
|
+
const og = reorderObject(gate, GATE_FIELD_ORDER);
|
|
137
|
+
if (og.config && typeof og.config === 'object') {
|
|
138
|
+
og.config = sortNestedKeys(og.config);
|
|
139
|
+
}
|
|
140
|
+
return og;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(orderedStep.declaredOutputs)) {
|
|
144
|
+
orderedStep.declaredOutputs = orderedStep.declaredOutputs.map((d) => reorderObject(d, DECLARED_OUTPUT_FIELD_ORDER));
|
|
145
|
+
}
|
|
146
|
+
if (orderedStep.template && typeof orderedStep.template === 'object') {
|
|
147
|
+
const ot = reorderObject(orderedStep.template, TEMPLATE_FIELD_ORDER);
|
|
148
|
+
if (ot.metadata && typeof ot.metadata === 'object') {
|
|
149
|
+
ot.metadata = sortNestedKeys(ot.metadata);
|
|
150
|
+
}
|
|
151
|
+
orderedStep.template = ot;
|
|
152
|
+
}
|
|
153
|
+
return orderedStep;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return ordered;
|
|
157
|
+
}
|
|
158
|
+
// ─── YAML emission ────────────────────────────────────────────────────────
|
|
159
|
+
/**
|
|
160
|
+
* Emit a canonical YAML string for the given (already-reordered)
|
|
161
|
+
* frontmatter object. Always ends with a single `\n`.
|
|
162
|
+
*/
|
|
163
|
+
export function emitCanonicalYaml(ordered) {
|
|
164
|
+
return yaml.dump(ordered, {
|
|
165
|
+
noRefs: true, // no anchors/aliases
|
|
166
|
+
sortKeys: false, // we control order ourselves; js-yaml leaves it alone
|
|
167
|
+
lineWidth: -1, // never wrap long scalars
|
|
168
|
+
quotingType: '"', // double-quotes when quoting is needed
|
|
169
|
+
forceQuotes: false, // only quote when ambiguous
|
|
170
|
+
indent: 2,
|
|
171
|
+
flowLevel: -1, // always block style for objects/arrays (except where the YAML grammar requires flow)
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// ─── Public entry points ──────────────────────────────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* Canonicalize a parsed plan frontmatter object to its YAML string.
|
|
177
|
+
* Pure: same input → same byte-identical output.
|
|
178
|
+
*/
|
|
179
|
+
export function canonicalizeFrontmatter(plan) {
|
|
180
|
+
const ordered = canonicalizeFrontmatterObject(plan);
|
|
181
|
+
return emitCanonicalYaml(ordered);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Canonicalize a full plan file (frontmatter + body). Output shape:
|
|
185
|
+
*
|
|
186
|
+
* ---
|
|
187
|
+
* <frontmatter>
|
|
188
|
+
* ---
|
|
189
|
+
*
|
|
190
|
+
* <body>
|
|
191
|
+
*
|
|
192
|
+
* Body is normalized to LF-only, exactly one trailing newline, no
|
|
193
|
+
* trailing whitespace beyond that. Frontmatter and body are
|
|
194
|
+
* separated by `---` plus one blank line (markdown-friendly).
|
|
195
|
+
*/
|
|
196
|
+
export function canonicalizePlanFile(plan, body) {
|
|
197
|
+
const fm = canonicalizeFrontmatter(plan);
|
|
198
|
+
// js-yaml's dump always ends with a single \n; collapse any
|
|
199
|
+
// accidental trailing blanks just in case.
|
|
200
|
+
const fmTrimmed = fm.replace(/\n+$/, '\n');
|
|
201
|
+
// Body: LF-only, strip CR; strip any leading newlines (the
|
|
202
|
+
// frontmatter-body separator is owned by this function, not the
|
|
203
|
+
// caller's body string — a parsed file's body capture often starts
|
|
204
|
+
// with the blank line after the closing `---`); trim trailing
|
|
205
|
+
// whitespace; ensure exactly one trailing newline.
|
|
206
|
+
const bodyTrimmed = body.replace(/\r\n/g, '\n').replace(/^\n+/, '').replace(/\s+$/, '');
|
|
207
|
+
return `---\n${fmTrimmed}---\n\n${bodyTrimmed}\n`;
|
|
208
|
+
}
|