@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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo plan list` — list Plans in the current workspace.
|
|
3
|
+
*
|
|
4
|
+
* The third read-path verb after `plan get` and `plan validate`.
|
|
5
|
+
* Closes the "what's even in this workspace?" gap that operators
|
|
6
|
+
* hit immediately after `yolo context` (no in-CLI way to discover
|
|
7
|
+
* planIds without a curl detour). Maps directly to the substrate's
|
|
8
|
+
* `work.list_plans` route, which is already in
|
|
9
|
+
* `SUBSTRATE_CLI_PLAN_SCOPES`.
|
|
10
|
+
*
|
|
11
|
+
* Two output modes:
|
|
12
|
+
* - default (`--summary`): one-line table per plan with planId,
|
|
13
|
+
* state, version, latestRunId (or `—`), updatedAt (relative ISO
|
|
14
|
+
* date for at-a-glance recency).
|
|
15
|
+
* - `--json`: pretty-printed raw `plans[]` array. For jq /
|
|
16
|
+
* scripted operator pipelines.
|
|
17
|
+
*
|
|
18
|
+
* Filter: `--state <draft|active|archived>` server-side filter
|
|
19
|
+
* (passed as `?state=` query param; renamed from `authoringState`
|
|
20
|
+
* 2026-05-09, item 17). Avoids client-side filtering on workspaces
|
|
21
|
+
* with many plans.
|
|
22
|
+
*
|
|
23
|
+
* Exit codes:
|
|
24
|
+
* - 0 = success (zero plans is also success — empty list, not an error)
|
|
25
|
+
* - 1 = http (e.g., 401 from a token that's been revoked mid-session)
|
|
26
|
+
* - 64 = usage (bad --state, missing env, --workspace mismatch)
|
|
27
|
+
*/
|
|
28
|
+
import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
29
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
30
|
+
const ALLOWED_STATES = ['draft', 'active', 'archived'];
|
|
31
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
32
|
+
export async function runPlanList(options) {
|
|
33
|
+
const env = options.env ?? process.env;
|
|
34
|
+
const format = options.outputFormat ?? 'summary';
|
|
35
|
+
// 1) --state value (validated even though the server also validates,
|
|
36
|
+
// so we can fail fast without a network round-trip).
|
|
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
|
+
// 2) Substrate context
|
|
41
|
+
const auth = resolveSubstrateContext(env);
|
|
42
|
+
if (!auth.ok)
|
|
43
|
+
return fail('auth', auth.message);
|
|
44
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
45
|
+
// 3) Mint token
|
|
46
|
+
let mint;
|
|
47
|
+
try {
|
|
48
|
+
mint = await mintSubstrateToken({
|
|
49
|
+
commonApiUrl,
|
|
50
|
+
userToken,
|
|
51
|
+
sessionId,
|
|
52
|
+
scopes: SUBSTRATE_CLI_PLAN_SCOPES,
|
|
53
|
+
fetchImpl: options.fetchImpl,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof WorkClientError) {
|
|
58
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
59
|
+
}
|
|
60
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
61
|
+
}
|
|
62
|
+
// 4) --workspace assertion
|
|
63
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
64
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
65
|
+
}
|
|
66
|
+
// 5) GET /workspaces/<wsId>/plans (with optional ?state=)
|
|
67
|
+
const ctx = {
|
|
68
|
+
commonApiUrl,
|
|
69
|
+
delegatedToken: mint.token,
|
|
70
|
+
fetchImpl: options.fetchImpl,
|
|
71
|
+
};
|
|
72
|
+
const path = options.stateFilter
|
|
73
|
+
? `/workspaces/${mint.workspaceId}/plans?state=${options.stateFilter}`
|
|
74
|
+
: `/workspaces/${mint.workspaceId}/plans`;
|
|
75
|
+
const response = await authenticatedRequest(ctx, path, { method: 'GET' });
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const text = await safeReadText(response);
|
|
78
|
+
return fail('http', `work.list_plans failed: HTTP ${response.status} — ${text}`, {
|
|
79
|
+
status: response.status,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const json = (await response.json());
|
|
83
|
+
if (!Array.isArray(json.plans)) {
|
|
84
|
+
return fail('http', 'work.list_plans response missing `plans` array');
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
output: format === 'json'
|
|
89
|
+
? formatJson(json.plans)
|
|
90
|
+
: formatSummary(json.plans, mint.workspaceId, options.stateFilter),
|
|
91
|
+
plans: json.plans,
|
|
92
|
+
workspaceId: mint.workspaceId,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
96
|
+
/**
|
|
97
|
+
* One-line header + one row per plan, columns:
|
|
98
|
+
* `planId state v# latestRun updatedAt`. Right-padded so
|
|
99
|
+
* planIds align even when they vary in length. When the list is
|
|
100
|
+
* empty, prints a clear "(no plans)" hint instead of just a header.
|
|
101
|
+
*/
|
|
102
|
+
export function formatSummary(plans, workspaceId, stateFilter) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
const filterTag = stateFilter ? ` (filter: ${stateFilter})` : '';
|
|
105
|
+
lines.push(`Plans in workspace ${workspaceId}${filterTag}: ${plans.length}`);
|
|
106
|
+
if (plans.length === 0) {
|
|
107
|
+
lines.push(' (no plans)');
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
}
|
|
110
|
+
// Column widths from data — keeps short workspaces compact, won't
|
|
111
|
+
// visually break at scale.
|
|
112
|
+
const planIdCol = Math.max(8, ...plans.map((p) => p.planId.length));
|
|
113
|
+
const nameCol = Math.max(4, ...plans.map((p) => p.name.length));
|
|
114
|
+
const header = ` ${'planId'.padEnd(planIdCol)} ${'name'.padEnd(nameCol)} state version latestRun updatedAt`;
|
|
115
|
+
lines.push(header);
|
|
116
|
+
for (const p of plans) {
|
|
117
|
+
const planId = p.planId.padEnd(planIdCol);
|
|
118
|
+
const name = p.name.padEnd(nameCol);
|
|
119
|
+
const state = p.state.padEnd(8);
|
|
120
|
+
const version = `v${p.version}`.padEnd(7);
|
|
121
|
+
const latestRun = (p.latestRunId ?? '—').padEnd(9);
|
|
122
|
+
lines.push(` ${planId} ${name} ${state} ${version} ${latestRun} ${p.updatedAt}`);
|
|
123
|
+
}
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
function formatJson(plans) {
|
|
127
|
+
return JSON.stringify(plans, null, 2);
|
|
128
|
+
}
|
|
129
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
130
|
+
function fail(kind, message, detail) {
|
|
131
|
+
return { ok: false, kind, message, detail };
|
|
132
|
+
}
|
|
133
|
+
function describeError(err) {
|
|
134
|
+
return err instanceof Error ? err.message : String(err);
|
|
135
|
+
}
|
|
136
|
+
async function safeReadText(response) {
|
|
137
|
+
try {
|
|
138
|
+
return await response.text();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return '<no body>';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function exitCodeForFailure(kind) {
|
|
145
|
+
switch (kind) {
|
|
146
|
+
case 'usage':
|
|
147
|
+
case 'auth':
|
|
148
|
+
case 'workspace_mismatch':
|
|
149
|
+
return 64;
|
|
150
|
+
case 'http':
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo plan open <planId>` — print the webapp Plan-DAG URL for the
|
|
3
|
+
* given Plan in the calling session's workspace.
|
|
4
|
+
*
|
|
5
|
+
* Mints a substrate token to get the workspaceId, calls work.get_plan
|
|
6
|
+
* just to confirm the Plan exists (so a typo'd planId surfaces an
|
|
7
|
+
* `http` error instead of printing a URL that 404s in the browser),
|
|
8
|
+
* then prints the URL. No file I/O, no canonicalization, no Run.
|
|
9
|
+
*
|
|
10
|
+
* Output modes:
|
|
11
|
+
* - default: prints the URL on a single line — handy for piping
|
|
12
|
+
* into clipboard tools or `open`.
|
|
13
|
+
* - `--json`: emits `{ ok, planId, workspaceId, url }`.
|
|
14
|
+
*
|
|
15
|
+
* Exit codes (via the CLI wrapper):
|
|
16
|
+
* - 0 = success
|
|
17
|
+
* - 1 = http (plan not found, etc.)
|
|
18
|
+
* - 64 = usage error (bad planId, missing env trio, --workspace
|
|
19
|
+
* mismatch, derivation failed)
|
|
20
|
+
*/
|
|
21
|
+
import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
22
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
23
|
+
import { planDagUrl } from './webapp-url.js';
|
|
24
|
+
const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
25
|
+
export async function runPlanOpen(options) {
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
if (!PLAN_ID_REGEX.test(options.planId)) {
|
|
28
|
+
return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
|
|
29
|
+
}
|
|
30
|
+
const auth = resolveSubstrateContext(env);
|
|
31
|
+
if (!auth.ok)
|
|
32
|
+
return fail('auth', auth.message);
|
|
33
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
34
|
+
let mint;
|
|
35
|
+
try {
|
|
36
|
+
mint = await mintSubstrateToken({
|
|
37
|
+
commonApiUrl,
|
|
38
|
+
userToken,
|
|
39
|
+
sessionId,
|
|
40
|
+
scopes: SUBSTRATE_CLI_PLAN_SCOPES,
|
|
41
|
+
fetchImpl: options.fetchImpl,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err instanceof WorkClientError) {
|
|
46
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
47
|
+
}
|
|
48
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
49
|
+
}
|
|
50
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
51
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
52
|
+
}
|
|
53
|
+
// Existence check — keeps the CLI honest about typo'd planIds.
|
|
54
|
+
// Cheaper than fetching the full plan: just rely on the HTTP status.
|
|
55
|
+
const ctx = {
|
|
56
|
+
commonApiUrl,
|
|
57
|
+
delegatedToken: mint.token,
|
|
58
|
+
fetchImpl: options.fetchImpl,
|
|
59
|
+
};
|
|
60
|
+
const response = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, { method: 'GET' });
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const text = await safeReadText(response);
|
|
63
|
+
return fail('http', `work.get_plan failed: HTTP ${response.status} — ${text}`, { status: response.status });
|
|
64
|
+
}
|
|
65
|
+
const url = planDagUrl(env, mint.workspaceId, options.planId);
|
|
66
|
+
if (!url) {
|
|
67
|
+
return fail('usage', 'could not derive webapp URL from YOLO_COMMON_API_URL — set YOLO_WEBAPP_URL to override.');
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
planId: options.planId,
|
|
72
|
+
workspaceId: mint.workspaceId,
|
|
73
|
+
url,
|
|
74
|
+
output: url,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function fail(kind, message, detail) {
|
|
78
|
+
return { ok: false, kind, message, detail };
|
|
79
|
+
}
|
|
80
|
+
function describeError(err) {
|
|
81
|
+
return err instanceof Error ? err.message : String(err);
|
|
82
|
+
}
|
|
83
|
+
async function safeReadText(response) {
|
|
84
|
+
try {
|
|
85
|
+
return await response.text();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return '<no body>';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function exitCodeForFailure(kind) {
|
|
92
|
+
switch (kind) {
|
|
93
|
+
case 'usage':
|
|
94
|
+
case 'auth':
|
|
95
|
+
case 'workspace_mismatch':
|
|
96
|
+
return 64;
|
|
97
|
+
case 'http':
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo plan activate <planId>` / `yolo plan archive <planId>`.
|
|
3
|
+
*
|
|
4
|
+
* Out-of-band Plan-state transitions for plans already in the DB.
|
|
5
|
+
* The file-driven flow can author a plan as `active` from the start
|
|
6
|
+
* (Phase 8d.2 — substrate now accepts `state` on `work.create_plan`),
|
|
7
|
+
* but operators still need a way to:
|
|
8
|
+
* - flip an existing draft to active without rewriting the file
|
|
9
|
+
* - archive a plan that's done its job
|
|
10
|
+
* neither of which has a clean file-level expression.
|
|
11
|
+
*
|
|
12
|
+
* Flow:
|
|
13
|
+
* 1. Validate planId regex.
|
|
14
|
+
* 2. Resolve env trio.
|
|
15
|
+
* 3. Mint a session-bound delegated token
|
|
16
|
+
* (`work.get_plan` + `work.update_plan`, both already in
|
|
17
|
+
* `SUBSTRATE_CLI_PLAN_SCOPES`).
|
|
18
|
+
* 4. Optional `--workspace` sanity-check against the minted
|
|
19
|
+
* workspaceId.
|
|
20
|
+
* 5. GET the current plan to fetch (a) its current state — for the
|
|
21
|
+
* "already in target state" no-op — and (b) its current
|
|
22
|
+
* `version` for the `update_plan(baseVersion, …)` CAS.
|
|
23
|
+
* 6. PATCH with `{ baseVersion, mutations: [{ op: 'set-state',
|
|
24
|
+
* state: <target> }] }`. The substrate's `applyMutations`
|
|
25
|
+
* enforces the transition matrix (draft→active|archived,
|
|
26
|
+
* active→archived; archived is terminal; same-state is allowed
|
|
27
|
+
* and bumps version).
|
|
28
|
+
* 7. Refresh the lockfile entry's `lastImportedVersion` to the
|
|
29
|
+
* new DB version so a subsequent `yolo plan import` is a
|
|
30
|
+
* NO_CHANGE rather than a false-positive DIVERGED. Revision
|
|
31
|
+
* is unchanged because the file didn't change — same
|
|
32
|
+
* revision pointer, new version.
|
|
33
|
+
*
|
|
34
|
+
* Field rename note: the Plan-level state field was `authoringState`
|
|
35
|
+
* before 2026-05-09 and `state` after (item 17 of
|
|
36
|
+
* `docs/SUBSTRATE_IMPROVEMENTS.md`). The substrate accepts only `state`.
|
|
37
|
+
*
|
|
38
|
+
* Exit codes (used by the CLI wrapper):
|
|
39
|
+
* - 0 = success (transition applied OR no-op when already in target)
|
|
40
|
+
* - 1 = http (plan not found, version conflict, transition rejected)
|
|
41
|
+
* - 64 = usage (bad planId, missing env, --workspace mismatch)
|
|
42
|
+
*/
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { LockfileError, getEntry, readLockfile, resolveLockfilePath, setEntry, writeLockfile, } from './lockfile.js';
|
|
45
|
+
import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
46
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
47
|
+
const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
48
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
49
|
+
export async function runPlanStateTransition(options) {
|
|
50
|
+
const env = options.env ?? process.env;
|
|
51
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
52
|
+
// 1) planId regex
|
|
53
|
+
if (!PLAN_ID_REGEX.test(options.planId)) {
|
|
54
|
+
return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
|
|
55
|
+
}
|
|
56
|
+
// 2) env trio
|
|
57
|
+
const auth = resolveSubstrateContext(env);
|
|
58
|
+
if (!auth.ok)
|
|
59
|
+
return fail('auth', auth.message);
|
|
60
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
61
|
+
// 3) Mint token
|
|
62
|
+
let mint;
|
|
63
|
+
try {
|
|
64
|
+
mint = await mintSubstrateToken({
|
|
65
|
+
commonApiUrl,
|
|
66
|
+
userToken,
|
|
67
|
+
sessionId,
|
|
68
|
+
scopes: SUBSTRATE_CLI_PLAN_SCOPES,
|
|
69
|
+
fetchImpl: options.fetchImpl,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof WorkClientError) {
|
|
74
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
75
|
+
}
|
|
76
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
77
|
+
}
|
|
78
|
+
// 4) --workspace assertion
|
|
79
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
80
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
81
|
+
}
|
|
82
|
+
const ctx = {
|
|
83
|
+
commonApiUrl,
|
|
84
|
+
delegatedToken: mint.token,
|
|
85
|
+
fetchImpl: options.fetchImpl,
|
|
86
|
+
};
|
|
87
|
+
// 5) GET current plan (need version for CAS, state for no-op detection)
|
|
88
|
+
const getResponse = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, { method: 'GET' });
|
|
89
|
+
if (!getResponse.ok) {
|
|
90
|
+
const text = await safeReadText(getResponse);
|
|
91
|
+
return fail('http', `work.get_plan failed: HTTP ${getResponse.status} — ${text}`, { status: getResponse.status });
|
|
92
|
+
}
|
|
93
|
+
const getJson = (await getResponse.json());
|
|
94
|
+
if (!getJson.plan) {
|
|
95
|
+
return fail('http', 'work.get_plan response missing `plan` field');
|
|
96
|
+
}
|
|
97
|
+
const currentState = getJson.plan.state;
|
|
98
|
+
const baseVersion = getJson.plan.version;
|
|
99
|
+
// 6) No-op short-circuit OR PATCH. Either way, we end up with
|
|
100
|
+
// a definitive (state, version) pair that the lockfile must
|
|
101
|
+
// reflect — the substrate's view of the plan is the source
|
|
102
|
+
// of truth for `lastImportedVersion`. Skipping the lockfile
|
|
103
|
+
// refresh on the no-op path was the Codex R1 finding:
|
|
104
|
+
// if the lockfile pointed at a stale version (e.g., the
|
|
105
|
+
// plan was activated outside this CLI between two
|
|
106
|
+
// `yolo plan activate` invocations), retry would return
|
|
107
|
+
// success but leave the divergence to fire on the next
|
|
108
|
+
// `yolo plan import`.
|
|
109
|
+
let finalVersion;
|
|
110
|
+
if (currentState === options.targetState) {
|
|
111
|
+
// No-op: substrate would accept same-state and bump version,
|
|
112
|
+
// but we'd rather not churn the DB on idempotent calls. The
|
|
113
|
+
// GET-response version is what the lockfile must align to.
|
|
114
|
+
finalVersion = baseVersion;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const patchResponse = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, {
|
|
118
|
+
method: 'PATCH',
|
|
119
|
+
jsonBody: {
|
|
120
|
+
baseVersion,
|
|
121
|
+
mutations: [{ op: 'set-state', state: options.targetState }],
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
if (!patchResponse.ok) {
|
|
125
|
+
const text = await safeReadText(patchResponse);
|
|
126
|
+
return fail('http', `work.update_plan failed: HTTP ${patchResponse.status} — ${text}`, { status: patchResponse.status });
|
|
127
|
+
}
|
|
128
|
+
const patchJson = (await patchResponse.json());
|
|
129
|
+
if (typeof patchJson.version !== 'number') {
|
|
130
|
+
return fail('http', 'work.update_plan response missing version field');
|
|
131
|
+
}
|
|
132
|
+
finalVersion = patchJson.version;
|
|
133
|
+
}
|
|
134
|
+
// 7) Refresh the lockfile entry (if one exists for this
|
|
135
|
+
// workspace/plan). Same on both paths: revision pointer
|
|
136
|
+
// stays put (the file didn't change), version + timestamp
|
|
137
|
+
// realign to the substrate's current view. Best-effort skip
|
|
138
|
+
// when no entry exists (e.g., plan was created via curl,
|
|
139
|
+
// not import).
|
|
140
|
+
const plansDir = options.plansDir ?? path.resolve('.yolo', 'plans');
|
|
141
|
+
const lockfileResult = refreshLockfileEntry({
|
|
142
|
+
plansDir,
|
|
143
|
+
lockfileFlag: options.lockfileFlag,
|
|
144
|
+
workspaceId: mint.workspaceId,
|
|
145
|
+
planId: options.planId,
|
|
146
|
+
newVersion: finalVersion,
|
|
147
|
+
now,
|
|
148
|
+
});
|
|
149
|
+
if (!lockfileResult.ok)
|
|
150
|
+
return lockfileResult.error;
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
planId: options.planId,
|
|
154
|
+
workspaceId: mint.workspaceId,
|
|
155
|
+
fromState: currentState,
|
|
156
|
+
toState: options.targetState,
|
|
157
|
+
version: finalVersion,
|
|
158
|
+
noop: currentState === options.targetState,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function refreshLockfileEntry(opts) {
|
|
162
|
+
let lockfilePath;
|
|
163
|
+
let lockfile;
|
|
164
|
+
try {
|
|
165
|
+
lockfilePath = resolveLockfilePath(opts.plansDir, opts.lockfileFlag);
|
|
166
|
+
lockfile = readLockfile(lockfilePath);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
if (err instanceof LockfileError) {
|
|
170
|
+
return { ok: false, error: fail('lockfile', err.message, { code: err.code }) };
|
|
171
|
+
}
|
|
172
|
+
return { ok: false, error: fail('lockfile', describeError(err)) };
|
|
173
|
+
}
|
|
174
|
+
const existing = getEntry(lockfile, opts.workspaceId, opts.planId);
|
|
175
|
+
if (!existing) {
|
|
176
|
+
// Best-effort: no entry, no write. The plan was likely
|
|
177
|
+
// created via curl or a different operator's machine.
|
|
178
|
+
return { ok: true };
|
|
179
|
+
}
|
|
180
|
+
const refreshed = {
|
|
181
|
+
lastImportedRevision: existing.lastImportedRevision,
|
|
182
|
+
lastImportedVersion: opts.newVersion,
|
|
183
|
+
lastImportedAt: opts.now(),
|
|
184
|
+
};
|
|
185
|
+
setEntry(lockfile, opts.workspaceId, opts.planId, refreshed);
|
|
186
|
+
try {
|
|
187
|
+
writeLockfile(lockfilePath, lockfile);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
if (err instanceof LockfileError) {
|
|
191
|
+
return { ok: false, error: fail('lockfile', err.message, { code: err.code }) };
|
|
192
|
+
}
|
|
193
|
+
return { ok: false, error: fail('lockfile', describeError(err)) };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true };
|
|
196
|
+
}
|
|
197
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
198
|
+
function fail(kind, message, detail) {
|
|
199
|
+
return { ok: false, kind, message, detail };
|
|
200
|
+
}
|
|
201
|
+
function describeError(err) {
|
|
202
|
+
return err instanceof Error ? err.message : String(err);
|
|
203
|
+
}
|
|
204
|
+
async function safeReadText(response) {
|
|
205
|
+
try {
|
|
206
|
+
return await response.text();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return '<no body>';
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export function exitCodeForFailure(kind) {
|
|
213
|
+
switch (kind) {
|
|
214
|
+
case 'usage':
|
|
215
|
+
case 'auth':
|
|
216
|
+
case 'workspace_mismatch':
|
|
217
|
+
return 64;
|
|
218
|
+
case 'http':
|
|
219
|
+
case 'lockfile':
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export function formatSuccess(result) {
|
|
224
|
+
if (result.noop) {
|
|
225
|
+
return `OK: plan '${result.planId}' is already ${result.toState} (workspace ${result.workspaceId}, version ${result.version})`;
|
|
226
|
+
}
|
|
227
|
+
return `OK: plan '${result.planId}' transitioned ${result.fromState} → ${result.toState} (workspace ${result.workspaceId}, version ${result.version})`;
|
|
228
|
+
}
|