@hiveku-apps/sync 0.3.6 → 0.4.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 +75 -5
- package/bin/hiveku-sync.mjs +113 -25
- package/package.json +1 -1
- package/src/codex-prompt-tools.json +2 -1
- package/src/codex.mjs +12 -5
- package/src/init.mjs +43 -14
- package/src/projects.mjs +126 -69
- package/src/pullRecord.mjs +98 -0
- package/src/pullTree.mjs +211 -0
- package/src/push.mjs +230 -94
- package/src/pushGuards.mjs +100 -0
- package/src/saveFiles.mjs +111 -0
- package/src/versionName.mjs +569 -0
- package/src/versions.mjs +811 -0
package/src/versions.mjs
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versions of a Hiveku site project, from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* "Your site" (the branch named main) keeps versions: a save is live in the
|
|
5
|
+
* preview and is what a deploy ships, but it is not a version until
|
|
6
|
+
* project_vcs_commit runs with NO files, which saves everything not yet in a
|
|
7
|
+
* version as ONE version (a "promote"). A branch works the same way on its
|
|
8
|
+
* working tree. Rolling back is append-only: it writes a NEW version equal to
|
|
9
|
+
* an older one, so it can itself be undone, and it never publishes anything.
|
|
10
|
+
*
|
|
11
|
+
* This module holds:
|
|
12
|
+
* - saveVersion: the promote every writer here uses (push, commit), with
|
|
13
|
+
* source 'sync_cli' and never any files or deletedFiles (re-sending
|
|
14
|
+
* deletions on main would delete again);
|
|
15
|
+
* - chooseVersionName / versionAfterPush: the name of a version covers
|
|
16
|
+
* everything waiting (project_vcs_status), never one run's own files, and
|
|
17
|
+
* Hiveku names the first version over a legacy head itself;
|
|
18
|
+
* - commitRetryCommand: the exact command that saves a version that failed;
|
|
19
|
+
* - runVersionCommit: `hiveku-sync commit` (a promote, named by -m or from
|
|
20
|
+
* what is waiting);
|
|
21
|
+
* - runVersions: `hiveku-sync versions` (the history, newest first);
|
|
22
|
+
* - runRollback: `hiveku-sync rollback <version>` (dry run unless --apply,
|
|
23
|
+
* and --apply needs the id the dry run printed, --confirm).
|
|
24
|
+
*
|
|
25
|
+
* Builder contract (hiveku_builder feature/versions-core): POST vcs/commit,
|
|
26
|
+
* GET vcs/status, POST vcs/rollback, GET vcs/history, reached through the MCP
|
|
27
|
+
* tools project_vcs_commit, project_vcs_status, project_vcs_rollback and
|
|
28
|
+
* project_vcs_history. Anything a person reads here says "version" and calls
|
|
29
|
+
* main "Your site".
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { sep } from 'node:path';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
clientFromCwd,
|
|
36
|
+
findProjectFolder,
|
|
37
|
+
refuseExternal,
|
|
38
|
+
resolveProject,
|
|
39
|
+
} from './projects.mjs';
|
|
40
|
+
import { describeChanges, versionNameProblem } from './versionName.mjs';
|
|
41
|
+
|
|
42
|
+
export const VERSION_SOURCE = 'sync_cli';
|
|
43
|
+
/** Exit code when files ARE saved but the version was not. */
|
|
44
|
+
export const EXIT_VERSION_FAILED = 2;
|
|
45
|
+
/** Exit code when something the command set out to do failed. */
|
|
46
|
+
export const EXIT_FAILED = 1;
|
|
47
|
+
/** Exit code when a push's files (and version) ARE saved and only its deploy failed. */
|
|
48
|
+
export const EXIT_DEPLOY_FAILED = 3;
|
|
49
|
+
|
|
50
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
51
|
+
const EXAMPLE_NAME = 'Updated the pricing section on the Home page';
|
|
52
|
+
|
|
53
|
+
// ── Small shared helpers ─────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** A branch name as the tools take it: null for Your site (main, blank, absent). */
|
|
56
|
+
export function normalizeBranch(branch) {
|
|
57
|
+
if (typeof branch !== 'string') return null;
|
|
58
|
+
const trimmed = branch.trim();
|
|
59
|
+
return trimmed && trimmed !== 'main' ? trimmed : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The --branch flag as given: undefined when absent, else the branch (null for
|
|
64
|
+
* Your site). A bare --branch with no name is refused, never read as Your site.
|
|
65
|
+
*/
|
|
66
|
+
export function branchFlag(branch) {
|
|
67
|
+
if (branch === undefined || branch === null) return undefined;
|
|
68
|
+
if (typeof branch !== 'string' || !branch.trim()) {
|
|
69
|
+
throw new Error('Pass a branch name: --branch <name> (or --branch main for Your site).');
|
|
70
|
+
}
|
|
71
|
+
return normalizeBranch(branch);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How a person reads the branch: "Your site" or branch "x". */
|
|
75
|
+
export function branchLabel(branch) {
|
|
76
|
+
return branch ? `branch "${branch}"` : 'Your site';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read a failed MCP tool call. HivekuMcpClient.callTool throws
|
|
81
|
+
* `Tool <name> errored: <json>`, where the JSON is the proxy's envelope
|
|
82
|
+
* { error, status, details: <the route's body, with its code>, ... }, or
|
|
83
|
+
* { error: "Unknown tool: <name>" } from a server that lacks the tool.
|
|
84
|
+
*/
|
|
85
|
+
export function readToolError(err) {
|
|
86
|
+
const message = String((err && err.message) || err || '');
|
|
87
|
+
const at = message.indexOf('errored: ');
|
|
88
|
+
let envelope = null;
|
|
89
|
+
if (at !== -1) {
|
|
90
|
+
try {
|
|
91
|
+
envelope = JSON.parse(message.slice(at + 'errored: '.length));
|
|
92
|
+
} catch {
|
|
93
|
+
envelope = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const details = envelope && typeof envelope.details === 'object' && envelope.details ? envelope.details : {};
|
|
97
|
+
const status = typeof envelope?.status === 'number' ? envelope.status : null;
|
|
98
|
+
const text = String(details.error || envelope?.error || message);
|
|
99
|
+
let code = typeof details.code === 'string' ? details.code : typeof envelope?.code === 'string' ? envelope.code : null;
|
|
100
|
+
if (!code && /^Unknown tool:/.test(String(envelope?.error || ''))) code = 'unknown_tool';
|
|
101
|
+
if (!code && /not in this connection's tool profile/i.test(message)) code = 'unknown_tool';
|
|
102
|
+
// No route on this builder yet: Next answers its own HTML 404 page.
|
|
103
|
+
if (!code && status === 404 && /text\/html/i.test(String(envelope?.content_type || ''))) code = 'route_unavailable';
|
|
104
|
+
if (!code && status === 404 && /project not found/i.test(text)) code = 'project_missing';
|
|
105
|
+
if (!code && /did not respond within the tool timeout/i.test(text)) code = 'timeout';
|
|
106
|
+
return { status, code, text, details, raw: message };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sleep(ms) {
|
|
110
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The name typed with -m, checked. Undefined when none was typed. Throws when
|
|
115
|
+
* it is not a plain version name: the builder keeps a hiveku-sync name
|
|
116
|
+
* exactly as sent, so this is the only check it gets.
|
|
117
|
+
*/
|
|
118
|
+
export function checkedVersionName(name) {
|
|
119
|
+
if (name === undefined || name === null) return undefined;
|
|
120
|
+
const trimmed = String(name).trim();
|
|
121
|
+
const problem = versionNameProblem(trimmed);
|
|
122
|
+
if (problem) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`"${trimmed}" can't be used as a version name: ${problem}. ` +
|
|
125
|
+
'The site owner reads version names, so describe what changed for visitors in plain words, ' +
|
|
126
|
+
`for example "${EXAMPLE_NAME}". Nothing was changed.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return trimmed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Saving a version (the promote) ───────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** Answers that go away on their own; the promote is tried once more. */
|
|
135
|
+
const RETRY_CODES = new Set(['branch_busy', 'branch_changed', 'idempotency_pending', 'branch_tree_unavailable']);
|
|
136
|
+
|
|
137
|
+
/** Why a version could not be saved, in words for a person. */
|
|
138
|
+
function versionFailureReason(error, branch) {
|
|
139
|
+
switch (error.code) {
|
|
140
|
+
case 'unknown_tool':
|
|
141
|
+
return 'this Hiveku connection does not offer versions yet';
|
|
142
|
+
case 'route_unavailable':
|
|
143
|
+
return 'versions are not available on Hiveku yet (the platform is updating)';
|
|
144
|
+
case 'branch_busy':
|
|
145
|
+
case 'idempotency_pending':
|
|
146
|
+
return 'someone else was saving at the same moment';
|
|
147
|
+
case 'branch_changed':
|
|
148
|
+
return `${branchLabel(branch)} changed while the version was being saved`;
|
|
149
|
+
case 'branch_tree_unavailable':
|
|
150
|
+
return 'Hiveku could not read the files for a moment';
|
|
151
|
+
case 'branch_missing':
|
|
152
|
+
return `there is no ${branchLabel(branch)} on this project`;
|
|
153
|
+
case 'timeout':
|
|
154
|
+
return 'Hiveku did not answer in time (it may still have saved it: check with `hiveku-sync versions` before trying again)';
|
|
155
|
+
default:
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
// A builder from before versions of Your site refuses a save with no files.
|
|
159
|
+
if (error.status === 400 && /at least one file|message is required/i.test(error.text)) {
|
|
160
|
+
return `this Hiveku server cannot save versions of ${branchLabel(branch)} yet (the platform is updating)`;
|
|
161
|
+
}
|
|
162
|
+
return `Hiveku answered: ${error.text}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The name a branch version gets when no name was given and the server is
|
|
167
|
+
* one that requires a name (production before versions of Your site names a
|
|
168
|
+
* branch version only when it is given one).
|
|
169
|
+
*/
|
|
170
|
+
export const BRANCH_FALLBACK_NAME = 'Saved the latest changes on this branch';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Save everything on Hiveku that is not in a version yet as ONE version
|
|
174
|
+
* (project_vcs_commit with no files and no deletedFiles). Never throws for a
|
|
175
|
+
* refusal: returns
|
|
176
|
+
* { outcome: 'saved', version, name } a new version,
|
|
177
|
+
* { outcome: 'nothing_new', latest_version } every change already is one,
|
|
178
|
+
* { outcome: 'failed', reason, error } not saved (files untouched).
|
|
179
|
+
* `name` undefined lets the server name it from what changed.
|
|
180
|
+
*/
|
|
181
|
+
export async function saveVersion({ client, projectId, branch = null, name, retryDelayMs = 2000 }) {
|
|
182
|
+
const args = { project_id: projectId, source: VERSION_SOURCE };
|
|
183
|
+
if (name) args.message = name;
|
|
184
|
+
if (branch) args.branch = branch;
|
|
185
|
+
let lastError = null;
|
|
186
|
+
let namedForOldServer = false;
|
|
187
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
const body = await client.callToolJson('project_vcs_commit', args);
|
|
190
|
+
const data = body && typeof body === 'object' ? body.data : null;
|
|
191
|
+
if (data === null && body?.clean === true) {
|
|
192
|
+
return { outcome: 'nothing_new', latest_version: body.latest_version ?? null };
|
|
193
|
+
}
|
|
194
|
+
if (!data || typeof data !== 'object') {
|
|
195
|
+
lastError = { code: null, status: null, text: 'the answer had no version in it', details: {} };
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
return { outcome: 'saved', version: data, name: data.message || name || null };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const error = readToolError(err);
|
|
201
|
+
if (error.code === 'nothing_to_commit') {
|
|
202
|
+
return { outcome: 'nothing_new', latest_version: error.details.latest_version ?? null };
|
|
203
|
+
}
|
|
204
|
+
lastError = error;
|
|
205
|
+
// An older server names no branch version itself: give it a plain one, once.
|
|
206
|
+
if (branch && !args.message && !namedForOldServer && error.status === 400 && /message is required/i.test(error.text)) {
|
|
207
|
+
namedForOldServer = true;
|
|
208
|
+
args.message = BRANCH_FALLBACK_NAME;
|
|
209
|
+
attempt -= 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (attempt < 2 && RETRY_CODES.has(error.code)) {
|
|
213
|
+
await sleep(retryDelayMs);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { outcome: 'failed', reason: versionFailureReason(lastError, branch), error: lastError };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Print what saveVersion did, for a person. `filesSaved` says whether this run
|
|
224
|
+
* saved files, so a failure says plainly that they ARE saved. `retryCommand`
|
|
225
|
+
* is the exact command that saves the version again (commitRetryCommand).
|
|
226
|
+
*/
|
|
227
|
+
export function reportVersion(result, { branch = null, filesSaved = false, retryCommand = 'hiveku-sync commit' } = {}) {
|
|
228
|
+
if (result.outcome === 'saved') {
|
|
229
|
+
const shown = result.name ? `"${result.name}"` : 'a new version';
|
|
230
|
+
console.log(` Saved as a version of ${branchLabel(branch)}: ${shown}`);
|
|
231
|
+
const saved = result.version?.saved_before;
|
|
232
|
+
if (saved && saved.name) console.log(` Changes that were waiting first were saved as their own version: "${saved.name}"`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (result.outcome === 'nothing_new') {
|
|
236
|
+
const latest = result.latest_version?.message;
|
|
237
|
+
console.log(
|
|
238
|
+
` Nothing new to save as a version: every change on ${branchLabel(branch)} is already in one` +
|
|
239
|
+
(latest ? ` (latest: "${latest}").` : '.'),
|
|
240
|
+
);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const lead = filesSaved
|
|
244
|
+
? 'Your files ARE saved on Hiveku, but they were not saved as a version'
|
|
245
|
+
: 'The version was not saved (your files on Hiveku are unchanged)';
|
|
246
|
+
console.log(` ${lead}: ${result.reason}.`);
|
|
247
|
+
console.log(` To try saving the version again, run: ${retryCommand}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One shell argument, always quoted (a version name). */
|
|
251
|
+
export function quotedArg(text) {
|
|
252
|
+
const value = String(text);
|
|
253
|
+
if (!/["$`\\!]/.test(value)) return `"${value}"`;
|
|
254
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One shell argument, quoted only when it needs to be (a slug, a branch). */
|
|
258
|
+
export function shellArg(text) {
|
|
259
|
+
const value = String(text);
|
|
260
|
+
return /^[A-Za-z0-9._/:=@-]+$/.test(value) ? value : quotedArg(value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The exact `hiveku-sync commit` that saves the version a push did not: the
|
|
265
|
+
* project by name (so it works from the account folder too), the branch the
|
|
266
|
+
* push wrote to (--branch main when the folder follows a branch but the push
|
|
267
|
+
* went to Your site), and the name typed with -m. A name the push made up is
|
|
268
|
+
* left out: commit names the version from everything that is waiting.
|
|
269
|
+
*/
|
|
270
|
+
export function commitRetryCommand({ slug, branch = null, recordedBranch = null, typedName } = {}) {
|
|
271
|
+
const parts = ['hiveku-sync commit'];
|
|
272
|
+
if (slug) parts.push(shellArg(slug));
|
|
273
|
+
if (branch) parts.push(`--branch ${shellArg(branch)}`);
|
|
274
|
+
else if (normalizeBranch(recordedBranch)) parts.push('--branch main');
|
|
275
|
+
if (typedName) parts.push(`-m ${quotedArg(typedName)}`);
|
|
276
|
+
return parts.join(' ');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Which branch a push writes to: --branch when given (main = Your site),
|
|
281
|
+
* else the branch the folder was pulled from. A folder pulled from a branch
|
|
282
|
+
* by hiveku-sync 0.3.x holds Your site's files (that version never read
|
|
283
|
+
* branches) and has no working_tree_etag recorded; pushing it to the branch
|
|
284
|
+
* would overwrite the branch's own work, so that needs --branch said out loud.
|
|
285
|
+
*/
|
|
286
|
+
export function resolvePushBranch({ branch, info = {} }) {
|
|
287
|
+
const flagged = branchFlag(branch);
|
|
288
|
+
if (flagged !== undefined) return flagged;
|
|
289
|
+
const recorded = normalizeBranch(info.branch);
|
|
290
|
+
if (recorded && !Object.prototype.hasOwnProperty.call(info, 'working_tree_etag')) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`This folder was pulled for branch "${recorded}" by an older hiveku-sync, which downloaded Your site's files ` +
|
|
293
|
+
`instead of the branch's. Pushing it to the branch could overwrite work saved there. Pull it again with ` +
|
|
294
|
+
`\`hiveku-sync pull-project <slug> --branch ${recorded}\`, or pass --branch ${recorded} to push anyway ` +
|
|
295
|
+
'(or --branch main for Your site). Nothing was changed.',
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return recorded;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Project resolution for commit / versions / rollback ──────────────────────
|
|
302
|
+
|
|
303
|
+
function accountBaseDir(cwd = process.cwd()) {
|
|
304
|
+
const idx = cwd.indexOf(`${sep}projects${sep}`);
|
|
305
|
+
return idx > -1 ? cwd.slice(0, idx) : cwd;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The project a command acts on: the pulled folder (by slug, or the one cwd
|
|
310
|
+
* is in) first, else a slug or id resolved on the account. External sites
|
|
311
|
+
* are refused.
|
|
312
|
+
*/
|
|
313
|
+
async function resolveTarget({ slugOrId, client, baseDir }) {
|
|
314
|
+
let found = null;
|
|
315
|
+
try {
|
|
316
|
+
found = await findProjectFolder({ slug: slugOrId, baseDir });
|
|
317
|
+
} catch {
|
|
318
|
+
found = null;
|
|
319
|
+
}
|
|
320
|
+
if (found) {
|
|
321
|
+
refuseExternal(found.info);
|
|
322
|
+
return { projectId: found.info.project_id, slug: found.slug, info: found.info };
|
|
323
|
+
}
|
|
324
|
+
if (!slugOrId) {
|
|
325
|
+
throw new Error('No project folder detected: cd into ./projects/<slug>/ or pass a project slug or id.');
|
|
326
|
+
}
|
|
327
|
+
const project = await resolveProject(client, slugOrId);
|
|
328
|
+
refuseExternal(project);
|
|
329
|
+
return { projectId: project.id, slug: project.slug || project.name || project.id, info: { project_type: project.project_type } };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function clientAndBase({ client, profile, baseDir }) {
|
|
333
|
+
const base = baseDir || accountBaseDir();
|
|
334
|
+
if (client) return { client, baseDir: base };
|
|
335
|
+
const resolved = await clientFromCwd({ profileOverride: profile });
|
|
336
|
+
return { client: resolved.client, baseDir: base };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** project_vcs_status, or null when this server or connection cannot answer it. */
|
|
340
|
+
export async function readVersionStatus({ client, projectId, branch = null, detail = true }) {
|
|
341
|
+
const args = { project_id: projectId };
|
|
342
|
+
if (branch) args.branch = branch;
|
|
343
|
+
if (detail) args.detail = 'files';
|
|
344
|
+
try {
|
|
345
|
+
const body = await client.callToolJson('project_vcs_status', args);
|
|
346
|
+
return body?.data && typeof body.data === 'object' ? body.data : null;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
const error = readToolError(err);
|
|
349
|
+
if (error.code === 'branch_missing') throw new Error(`There is no ${branchLabel(branch)} on this project.`);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A name for what is waiting, from status' changed_files; undefined when unknown. */
|
|
355
|
+
export function nameFromStatus(status) {
|
|
356
|
+
const files = Array.isArray(status?.changed_files) ? status.changed_files : null;
|
|
357
|
+
if (!files || files.length === 0) return undefined;
|
|
358
|
+
const lists = { added: [], modified: [], removed: [] };
|
|
359
|
+
for (const entry of files) {
|
|
360
|
+
if (!entry || typeof entry.path !== 'string') continue;
|
|
361
|
+
if (entry.status === 'added') lists.added.push(entry.path);
|
|
362
|
+
else if (entry.status === 'removed') lists.removed.push(entry.path);
|
|
363
|
+
else lists.modified.push(entry.path);
|
|
364
|
+
}
|
|
365
|
+
const name = describeChanges(lists);
|
|
366
|
+
return name === 'No changes' ? undefined : name;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Why status cannot name what is waiting, so Hiveku names the version:
|
|
371
|
+
* legacy_head Your site's last version predates versions; the first one
|
|
372
|
+
* after it holds the whole site and is named "Your site up to
|
|
373
|
+
* <date>" by the server, never after one page;
|
|
374
|
+
* no_version_yet Your site has no version at all (the same honest name);
|
|
375
|
+
* unknown Hiveku could not measure what is waiting.
|
|
376
|
+
*/
|
|
377
|
+
const SERVER_NAMES_IT = new Set(['legacy_head', 'no_version_yet', 'unknown']);
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The name to send when saving what is waiting as a version. A version saves
|
|
381
|
+
* EVERYTHING waiting on Your site (or the branch), from every writer: the
|
|
382
|
+
* Hiveku AI, the editor, other tools. So a name comes from the whole list
|
|
383
|
+
* project_vcs_status reports (read after this run's files landed), never from
|
|
384
|
+
* one run's own files. `status` null means this server cannot report it (an
|
|
385
|
+
* older one): then `own` ({ added, modified, removed }), the files this run
|
|
386
|
+
* changed, names it. Returns { name, basis: 'typed'|'waiting'|'own'|'server' };
|
|
387
|
+
* name undefined lets Hiveku name it.
|
|
388
|
+
*/
|
|
389
|
+
export function chooseVersionName({ typedName, status = null, own = null } = {}) {
|
|
390
|
+
if (typedName) return { name: typedName, basis: 'typed' };
|
|
391
|
+
if (!status) {
|
|
392
|
+
const name = own ? describeChanges(own) : undefined;
|
|
393
|
+
return name && name !== 'No changes' ? { name, basis: 'own' } : { name: undefined, basis: 'server' };
|
|
394
|
+
}
|
|
395
|
+
if (SERVER_NAMES_IT.has(status.uncommitted_reason)) return { name: undefined, basis: 'server' };
|
|
396
|
+
const name = nameFromStatus(status);
|
|
397
|
+
return name ? { name, basis: 'waiting' } : { name: undefined, basis: 'server' };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Status as it will read after this run's own changes land (for a dry run):
|
|
402
|
+
* its changed_files plus the run's own, the run's own status winning. A list
|
|
403
|
+
* Hiveku could not report (null) stays unknown.
|
|
404
|
+
*/
|
|
405
|
+
export function statusWithOwnChanges(status, own = {}) {
|
|
406
|
+
if (!status || !Array.isArray(status.changed_files)) return status;
|
|
407
|
+
const byPath = new Map(status.changed_files.filter((f) => f && typeof f.path === 'string').map((f) => [f.path, f.status]));
|
|
408
|
+
for (const path of own.added || []) byPath.set(path, byPath.get(path) === 'removed' ? 'modified' : 'added');
|
|
409
|
+
for (const path of own.modified || []) byPath.set(path, byPath.get(path) === 'added' ? 'added' : 'modified');
|
|
410
|
+
for (const path of own.removed || []) byPath.set(path, 'removed');
|
|
411
|
+
const changedFiles = [...byPath].map(([path, fileStatus]) => ({ path, status: fileStatus }));
|
|
412
|
+
const reason = status.uncommitted_reason || (changedFiles.length ? 'changed' : null);
|
|
413
|
+
return { ...status, uncommitted_reason: reason, changed_files: changedFiles };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** project_vcs_status that never throws (null when it cannot be read). */
|
|
417
|
+
export async function tryReadVersionStatus(args) {
|
|
418
|
+
try {
|
|
419
|
+
return await readVersionStatus(args);
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** What a person reads about the name a version will get. */
|
|
426
|
+
export function describeChosenName(choice) {
|
|
427
|
+
if (choice.name) return `"${choice.name}"`;
|
|
428
|
+
return 'named by Hiveku from everything it holds';
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Save the version a push ends with, after every file landed: read what is
|
|
433
|
+
* waiting (so the name covers other writers' changes too), save, and report
|
|
434
|
+
* with the exact retry command. `own` is what this push changed.
|
|
435
|
+
*/
|
|
436
|
+
export async function versionAfterPush({ client, projectId, branch = null, typedName, own, slug, recordedBranch, retryDelayMs }) {
|
|
437
|
+
const status = typedName ? null : await tryReadVersionStatus({ client, projectId, branch });
|
|
438
|
+
const choice = chooseVersionName({ typedName, status, own });
|
|
439
|
+
const result = await saveVersion({ client, projectId, branch, name: choice.name, retryDelayMs });
|
|
440
|
+
reportVersion(result, {
|
|
441
|
+
branch,
|
|
442
|
+
filesSaved: true,
|
|
443
|
+
retryCommand: commitRetryCommand({ slug, branch, recordedBranch, typedName }),
|
|
444
|
+
});
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── hiveku-sync commit ───────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* `hiveku-sync commit [<slug>] [-m "name"] [--branch <name>] [--dry-run]`:
|
|
452
|
+
* save what is on Hiveku and not in a version yet as one version. It sends no
|
|
453
|
+
* files: local edits that were not pushed are not in it.
|
|
454
|
+
*/
|
|
455
|
+
export async function runVersionCommit({ slug, name, branch, profile, dryRun = false, client: injectedClient, baseDir: baseDirOverride } = {}) {
|
|
456
|
+
const typedName = checkedVersionName(name);
|
|
457
|
+
const flagged = branchFlag(branch);
|
|
458
|
+
const { client, baseDir } = await clientAndBase({ client: injectedClient, profile, baseDir: baseDirOverride });
|
|
459
|
+
const target = await resolveTarget({ slugOrId: slug, client, baseDir });
|
|
460
|
+
const targetBranch = flagged !== undefined ? flagged : normalizeBranch(target.info.branch);
|
|
461
|
+
|
|
462
|
+
let status = null;
|
|
463
|
+
if (!typedName || dryRun) {
|
|
464
|
+
status = await readVersionStatus({ client, projectId: target.projectId, branch: targetBranch });
|
|
465
|
+
}
|
|
466
|
+
const versionName = chooseVersionName({ typedName, status }).name;
|
|
467
|
+
|
|
468
|
+
if (dryRun) {
|
|
469
|
+
console.log(`[dry run] ${branchLabel(targetBranch)} (${target.slug}):`);
|
|
470
|
+
if (status && status.uncommitted === false && status.uncommitted_reason !== 'unknown') {
|
|
471
|
+
console.log(' Nothing is waiting: every change is already in a version.');
|
|
472
|
+
} else if (status) {
|
|
473
|
+
const files = status.latest_changes?.files;
|
|
474
|
+
console.log(` Waiting to be saved as a version${typeof files === 'number' ? `: ${files} file${files === 1 ? '' : 's'}` : ''}.`);
|
|
475
|
+
} else {
|
|
476
|
+
console.log(' Hiveku could not say what is waiting (this server does not report it yet).');
|
|
477
|
+
}
|
|
478
|
+
console.log(` Name: ${versionName ? `"${versionName}"` : 'chosen by Hiveku from what changed'}`);
|
|
479
|
+
return { dryRun: true, name: versionName ?? null, status, exit_code: 0 };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const result = await saveVersion({ client, projectId: target.projectId, branch: targetBranch, name: versionName });
|
|
483
|
+
console.log(`${target.slug}:`);
|
|
484
|
+
reportVersion(result, {
|
|
485
|
+
branch: targetBranch,
|
|
486
|
+
filesSaved: false,
|
|
487
|
+
retryCommand: commitRetryCommand({ slug: target.slug, branch: targetBranch, recordedBranch: target.info.branch, typedName }),
|
|
488
|
+
});
|
|
489
|
+
if (result.outcome !== 'failed') {
|
|
490
|
+
console.log(' Only what is already on Hiveku is included: run `hiveku-sync push` first to send local edits.');
|
|
491
|
+
}
|
|
492
|
+
return { ...result, exit_code: result.outcome === 'failed' ? EXIT_VERSION_FAILED : 0 };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── hiveku-sync versions ─────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
const SOURCE_LABEL = {
|
|
498
|
+
ai_turn: 'Hiveku AI',
|
|
499
|
+
editor_idle: 'saved automatically',
|
|
500
|
+
deploy: 'saved when published',
|
|
501
|
+
mcp: 'AI assistant or API key',
|
|
502
|
+
vscode: 'VS Code',
|
|
503
|
+
sync_cli: 'hiveku-sync',
|
|
504
|
+
rollback: 'rollback',
|
|
505
|
+
merge: 'branch merged in',
|
|
506
|
+
manual: 'dashboard',
|
|
507
|
+
github: 'GitHub',
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
function formatWhen(iso) {
|
|
511
|
+
const date = new Date(iso);
|
|
512
|
+
if (Number.isNaN(date.getTime())) return String(iso || '');
|
|
513
|
+
return date.toISOString().slice(0, 16).replace('T', ' ');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Every entry of project_vcs_history up to `limit`: one branch (Your site when null), or with allBranches every branch. */
|
|
517
|
+
async function readHistory({ client, projectId, branch, limit, allBranches = false }) {
|
|
518
|
+
const args = { project_id: projectId, limit };
|
|
519
|
+
if (!allBranches) args.branch = branch || 'main';
|
|
520
|
+
const body = await client.callToolJson('project_vcs_history', args);
|
|
521
|
+
return Array.isArray(body?.data) ? body.data : [];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* `hiveku-sync versions [<slug>] [--branch <name>] [--limit N] [--json]`:
|
|
526
|
+
* the versions of Your site (or a branch), newest first, and what is waiting.
|
|
527
|
+
*/
|
|
528
|
+
export async function runVersions({ slug, branch, limit = 20, json = false, profile, client: injectedClient, baseDir: baseDirOverride } = {}) {
|
|
529
|
+
const flagged = branchFlag(branch);
|
|
530
|
+
const { client, baseDir } = await clientAndBase({ client: injectedClient, profile, baseDir: baseDirOverride });
|
|
531
|
+
const target = await resolveTarget({ slugOrId: slug, client, baseDir });
|
|
532
|
+
const targetBranch = flagged !== undefined ? flagged : normalizeBranch(target.info.branch);
|
|
533
|
+
const count = Math.min(500, Math.max(1, Number.parseInt(limit, 10) || 20));
|
|
534
|
+
|
|
535
|
+
const [status, entries] = await Promise.all([
|
|
536
|
+
readVersionStatus({ client, projectId: target.projectId, branch: targetBranch }),
|
|
537
|
+
readHistory({ client, projectId: target.projectId, branch: targetBranch, limit: count }),
|
|
538
|
+
]);
|
|
539
|
+
|
|
540
|
+
if (json) {
|
|
541
|
+
console.log(JSON.stringify({ project_id: target.projectId, branch: targetBranch || 'main', status, versions: entries }, null, 2));
|
|
542
|
+
return { status, versions: entries, exit_code: 0 };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
console.log(`Versions of ${branchLabel(targetBranch)} (${target.slug}), newest first:`);
|
|
546
|
+
if (status && status.uncommitted === true) {
|
|
547
|
+
const files = status.latest_changes?.files;
|
|
548
|
+
const summary = nameFromStatus(status) || status.latest_changes?.summary;
|
|
549
|
+
console.log(
|
|
550
|
+
` Not saved as a version yet${typeof files === 'number' ? `: ${files} file${files === 1 ? '' : 's'}` : ''}` +
|
|
551
|
+
(summary ? ` (${summary})` : '') +
|
|
552
|
+
'. `hiveku-sync commit` saves them.',
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (entries.length === 0) {
|
|
556
|
+
console.log(' No versions yet.');
|
|
557
|
+
return { status, versions: entries, exit_code: 0 };
|
|
558
|
+
}
|
|
559
|
+
for (const entry of entries) {
|
|
560
|
+
const who = entry.actor?.label || SOURCE_LABEL[entry.source] || '';
|
|
561
|
+
const how = entry.source && SOURCE_LABEL[entry.source] && SOURCE_LABEL[entry.source] !== who ? SOURCE_LABEL[entry.source] : '';
|
|
562
|
+
const by = [who, how].filter(Boolean).join(', ');
|
|
563
|
+
const live = Array.isArray(entry.live_on) && entry.live_on.length ? ` [live on ${entry.live_on.join(', ')}]` : '';
|
|
564
|
+
console.log(` ${formatWhen(entry.created_at)} ${entry.message || '(no name)'}${by ? ` (${by})` : ''}${live}`);
|
|
565
|
+
console.log(` id ${entry.id}`);
|
|
566
|
+
}
|
|
567
|
+
console.log('\nTo go back to one: hiveku-sync rollback <id> (shows what would change; nothing changes until you --apply)');
|
|
568
|
+
return { status, versions: entries, exit_code: 0 };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── hiveku-sync rollback ─────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
/** A full version id from a full id or an unambiguous prefix (8+ characters) found in the history. */
|
|
574
|
+
async function resolveVersionId({ client, projectId, branch, versionId }) {
|
|
575
|
+
const wanted = String(versionId || '').trim().toLowerCase();
|
|
576
|
+
if (UUID_RE.test(wanted)) return wanted;
|
|
577
|
+
if (wanted.length < 8 || !/^[0-9a-f-]+$/.test(wanted)) {
|
|
578
|
+
throw new Error('Give the version id from `hiveku-sync versions` (the whole id, or at least its first 8 characters).');
|
|
579
|
+
}
|
|
580
|
+
const matching = (entries) => entries.filter((entry) => String(entry.id || '').toLowerCase().startsWith(wanted));
|
|
581
|
+
let matches = matching(await readHistory({ client, projectId, branch, limit: 500 }));
|
|
582
|
+
// A rollback target can also be a version saved on another branch (one
|
|
583
|
+
// merged in, or the one this branch started from): look at every branch.
|
|
584
|
+
if (matches.length === 0) matches = matching(await readHistory({ client, projectId, branch: null, allBranches: true, limit: 500 }));
|
|
585
|
+
if (matches.length === 1) return matches[0].id;
|
|
586
|
+
if (matches.length === 0) {
|
|
587
|
+
throw new Error(`No version starting with ${wanted} in the last 500 versions. Give the whole id from \`hiveku-sync versions\`.`);
|
|
588
|
+
}
|
|
589
|
+
throw new Error(`More than one version starts with ${wanted}: give more of the id.`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The dry-run command for a version, as a person would type it again. It
|
|
594
|
+
* says --branch main when the folder follows a branch but the rollback is of
|
|
595
|
+
* Your site, or the command would act on the folder's branch.
|
|
596
|
+
*/
|
|
597
|
+
function dryRunCommand({ versionId, slug, branch, recordedBranch }) {
|
|
598
|
+
const parts = ['hiveku-sync rollback', versionId];
|
|
599
|
+
if (slug) parts.push(shellArg(slug));
|
|
600
|
+
if (branch) parts.push(`--branch ${shellArg(branch)}`);
|
|
601
|
+
else if (normalizeBranch(recordedBranch)) parts.push('--branch main');
|
|
602
|
+
return parts.join(' ');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 409 rollback_incomplete: files WERE written, so never "nothing was
|
|
607
|
+
* changed", and never "run the same command": when changes that were waiting
|
|
608
|
+
* were saved first, or the rollback was not recorded, the site's version
|
|
609
|
+
* moved and the old --confirm id is refused.
|
|
610
|
+
*/
|
|
611
|
+
function rollbackIncompleteText(error, { branch, versionId, slug, recordedBranch }) {
|
|
612
|
+
const details = error.details || {};
|
|
613
|
+
const applied = Array.isArray(details.applied) ? details.applied.length : 0;
|
|
614
|
+
const failed = Array.isArray(details.failed) ? details.failed : [];
|
|
615
|
+
const lines = [];
|
|
616
|
+
if (failed.length === 0) {
|
|
617
|
+
lines.push(
|
|
618
|
+
`Every file is back as it was in that version${applied ? ` (${applied} file${applied === 1 ? '' : 's'})` : ''}, ` +
|
|
619
|
+
'but the rollback was not saved as a new version. Nothing was lost.',
|
|
620
|
+
);
|
|
621
|
+
} else {
|
|
622
|
+
const total = applied + failed.length;
|
|
623
|
+
const shown = failed.slice(0, 5).join(', ') + (failed.length > 5 ? ` and ${failed.length - 5} more` : '');
|
|
624
|
+
lines.push(
|
|
625
|
+
`The rollback did not finish: ${failed.length} of ${total} file${total === 1 ? '' : 's'} could not be put back (${shown}). ` +
|
|
626
|
+
`Some files WERE changed: ${applied} ${applied === 1 ? 'is' : 'are'} already back as ${applied === 1 ? 'it was' : 'they were'}.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (details.saved_before && details.saved_before.name) {
|
|
630
|
+
lines.push(`Changes that were waiting were first saved as their own version: "${details.saved_before.name}".`);
|
|
631
|
+
}
|
|
632
|
+
const again = dryRunCommand({ versionId, slug, branch, recordedBranch });
|
|
633
|
+
if (failed.length === 0) {
|
|
634
|
+
lines.push(
|
|
635
|
+
`To save it as a version, run the dry run again (${again}), then the --apply command it prints; if it says there is ` +
|
|
636
|
+
`nothing to roll back, save it with \`${commitRetryCommand({ slug, branch, recordedBranch })}\` instead.`,
|
|
637
|
+
);
|
|
638
|
+
} else {
|
|
639
|
+
lines.push(`To finish, run the dry run again: ${again}, then the --apply command it prints.`);
|
|
640
|
+
}
|
|
641
|
+
return lines.join('\n');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function rollbackFailure(error, { branch, versionId, slug, recordedBranch }) {
|
|
645
|
+
switch (error.code) {
|
|
646
|
+
case 'branch_changed':
|
|
647
|
+
return `Someone changed ${branchLabel(branch)} since the dry run. Nothing was changed. Run the dry run again: ${dryRunCommand({ versionId, slug, branch, recordedBranch })}`;
|
|
648
|
+
case 'ai_turn_running':
|
|
649
|
+
return 'The Hiveku AI is working on this site right now. Nothing was changed. Try again when it has finished.';
|
|
650
|
+
case 'rollback_incomplete':
|
|
651
|
+
return rollbackIncompleteText(error, { branch, versionId, slug, recordedBranch });
|
|
652
|
+
case 'content_unavailable':
|
|
653
|
+
return "That version's files can no longer be read. Nothing was changed. It may still be restorable from Project history in the dashboard.";
|
|
654
|
+
case 'not_in_history':
|
|
655
|
+
return `That version is not part of the history of ${branchLabel(branch)}. Nothing was changed.`;
|
|
656
|
+
case 'commit_not_found':
|
|
657
|
+
return 'There is no version with that id on this project. Nothing was changed.';
|
|
658
|
+
case 'expected_head_required':
|
|
659
|
+
return 'Hiveku needs the id the dry run printed (--confirm). Nothing was changed.';
|
|
660
|
+
case 'stash_branch_readonly':
|
|
661
|
+
return 'That branch holds set-aside changes and cannot be rolled back. Nothing was changed.';
|
|
662
|
+
case 'branch_busy':
|
|
663
|
+
return 'Someone else is saving at this moment. Nothing was changed. Try again shortly.';
|
|
664
|
+
case 'branch_missing':
|
|
665
|
+
return `There is no ${branchLabel(branch)} on this project. Nothing was changed.`;
|
|
666
|
+
case 'content_too_large':
|
|
667
|
+
return 'This site is too large to roll back in one step here. Nothing was changed.';
|
|
668
|
+
case 'unknown_tool':
|
|
669
|
+
case 'route_unavailable':
|
|
670
|
+
return 'Rolling back is not available on this Hiveku connection yet (the platform is updating). Nothing was changed.';
|
|
671
|
+
case 'timeout':
|
|
672
|
+
return 'Hiveku did not answer in time. It may still be working: check `hiveku-sync versions` before trying again.';
|
|
673
|
+
default:
|
|
674
|
+
return `Hiveku could not roll back: ${error.text}`;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function countLine(files = {}) {
|
|
679
|
+
const parts = [];
|
|
680
|
+
if (files.changed) parts.push(`${files.changed} file${files.changed === 1 ? '' : 's'} go back to how they were`);
|
|
681
|
+
if (files.removed) parts.push(`${files.removed} added since then ${files.removed === 1 ? 'is' : 'are'} removed`);
|
|
682
|
+
if (files.added_back) parts.push(`${files.added_back} removed since then ${files.added_back === 1 ? 'comes' : 'come'} back`);
|
|
683
|
+
return parts.length ? parts.join(', ') : 'no files change';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function applyCommand({ versionId, slug, branch, recordedBranch, head, fingerprint, name }) {
|
|
687
|
+
const parts = [dryRunCommand({ versionId, slug, branch, recordedBranch })];
|
|
688
|
+
parts.push('--apply', `--confirm ${head}`);
|
|
689
|
+
if (fingerprint) parts.push(`--fingerprint ${fingerprint}`);
|
|
690
|
+
if (name) parts.push(`-m ${quotedArg(name)}`);
|
|
691
|
+
return parts.join(' ');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* `hiveku-sync rollback <version> [<slug>] [--branch <name>]` shows what going
|
|
696
|
+
* back to that version would change (a dry run; nothing changes).
|
|
697
|
+
* `... --apply --confirm <id> [--fingerprint <fp>]` applies it, only while
|
|
698
|
+
* Your site (or the branch) is still where the dry run saw it. It never
|
|
699
|
+
* publishes: `hiveku-sync deploy` is a separate step.
|
|
700
|
+
*/
|
|
701
|
+
export async function runRollback({
|
|
702
|
+
versionId,
|
|
703
|
+
slug,
|
|
704
|
+
branch,
|
|
705
|
+
apply = false,
|
|
706
|
+
head,
|
|
707
|
+
fingerprint,
|
|
708
|
+
name,
|
|
709
|
+
profile,
|
|
710
|
+
client: injectedClient,
|
|
711
|
+
baseDir: baseDirOverride,
|
|
712
|
+
} = {}) {
|
|
713
|
+
if (!versionId) throw new Error('Usage: hiveku-sync rollback <version id> [<slug>] (see `hiveku-sync versions`)');
|
|
714
|
+
const typedName = checkedVersionName(name);
|
|
715
|
+
const flagged = branchFlag(branch);
|
|
716
|
+
if (apply && (typeof head !== 'string' || !UUID_RE.test(head.trim()))) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
'Rolling back needs the dry run first. Run `hiveku-sync rollback <version id>` without --apply, check what it will ' +
|
|
719
|
+
'change, then run the --apply command it prints (it carries --confirm <id>). Nothing was changed.',
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const { client, baseDir } = await clientAndBase({ client: injectedClient, profile, baseDir: baseDirOverride });
|
|
723
|
+
const target = await resolveTarget({ slugOrId: slug, client, baseDir });
|
|
724
|
+
const targetBranch = flagged !== undefined ? flagged : normalizeBranch(target.info.branch);
|
|
725
|
+
const fullId = await resolveVersionId({ client, projectId: target.projectId, branch: targetBranch, versionId });
|
|
726
|
+
|
|
727
|
+
const args = { project_id: target.projectId, commit_id: fullId, dry_run: !apply };
|
|
728
|
+
if (targetBranch) args.branch = targetBranch;
|
|
729
|
+
if (apply) {
|
|
730
|
+
args.expected_head_commit_id = head.trim().toLowerCase();
|
|
731
|
+
if (typeof fingerprint === 'string' && fingerprint.trim()) args.expected_live_fingerprint = fingerprint.trim().toLowerCase();
|
|
732
|
+
if (typedName) args.message = typedName;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
let data;
|
|
736
|
+
try {
|
|
737
|
+
data = (await client.callToolJson('project_vcs_rollback', args))?.data;
|
|
738
|
+
} catch (err) {
|
|
739
|
+
const error = readToolError(err);
|
|
740
|
+
console.log(rollbackFailure(error, { branch: targetBranch, versionId, slug: slug || null, recordedBranch: target.info.branch }));
|
|
741
|
+
return { outcome: 'failed', error, exit_code: EXIT_FAILED };
|
|
742
|
+
}
|
|
743
|
+
if (!data || typeof data !== 'object') {
|
|
744
|
+
console.log('Hiveku gave no answer about the rollback. Check `hiveku-sync versions` before trying again.');
|
|
745
|
+
return { outcome: 'failed', exit_code: EXIT_FAILED };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const targetName = data.target?.name ? `"${data.target.name}"` : `version ${fullId}`;
|
|
749
|
+
const where = branchLabel(targetBranch);
|
|
750
|
+
|
|
751
|
+
if (!apply) {
|
|
752
|
+
console.log(`Roll ${where} back to ${targetName}${data.target?.created_at ? ` (${formatWhen(data.target.created_at)} UTC)` : ''}:`);
|
|
753
|
+
if (data.noop) {
|
|
754
|
+
console.log(` ${where} already matches that version. There is nothing to roll back.`);
|
|
755
|
+
return { outcome: 'dry_run', dry_run: data, exit_code: 0 };
|
|
756
|
+
}
|
|
757
|
+
console.log(` ${countLine(data.changes?.files)}.`);
|
|
758
|
+
const pages = Array.isArray(data.changes?.pages) ? data.changes.pages.map((page) => page.label).filter(Boolean) : [];
|
|
759
|
+
if (pages.length) console.log(` Pages: ${pages.slice(0, 6).join(', ')}${pages.length > 6 ? ` and ${pages.length - 6} more` : ''}.`);
|
|
760
|
+
const undone = data.versions_undone;
|
|
761
|
+
if (undone && undone.count) {
|
|
762
|
+
const newest = undone.newest?.[0]?.name;
|
|
763
|
+
console.log(` Undoes ${undone.count} newer version${undone.count === 1 ? '' : 's'}${newest ? `, the newest "${newest}"` : ''}. They stay in the history, so this can be undone too.`);
|
|
764
|
+
}
|
|
765
|
+
if (data.auto_version?.will_create) {
|
|
766
|
+
console.log(` Changes not saved as a version yet are saved first, as "${data.auto_version.name || 'Saved before rollback'}".`);
|
|
767
|
+
}
|
|
768
|
+
if (data.assets_affected) console.log(' Images, fonts and videos in the shared asset store do not roll back.');
|
|
769
|
+
if (data.ai_turn_running) console.log(' The Hiveku AI is working on this site right now: wait for it to finish before applying.');
|
|
770
|
+
if (data.live_includes_undone_work) {
|
|
771
|
+
console.log(' Your live site still shows the newer work. Rolling back does not change it; publish afterwards with `hiveku-sync deploy production`.');
|
|
772
|
+
}
|
|
773
|
+
console.log('\nThis was a dry run: nothing was changed.');
|
|
774
|
+
if (typeof data.head_commit_id !== 'string' || !data.head_commit_id) {
|
|
775
|
+
console.log(`${where} has no version to roll back from yet, so this cannot be applied.`);
|
|
776
|
+
return { outcome: 'dry_run', dry_run: data, exit_code: 0 };
|
|
777
|
+
}
|
|
778
|
+
console.log('To roll back, run:');
|
|
779
|
+
console.log(
|
|
780
|
+
` ${applyCommand({
|
|
781
|
+
versionId: fullId,
|
|
782
|
+
slug: slug || null,
|
|
783
|
+
branch: targetBranch,
|
|
784
|
+
recordedBranch: target.info.branch,
|
|
785
|
+
head: data.head_commit_id,
|
|
786
|
+
fingerprint: data.live_fingerprint,
|
|
787
|
+
name: typedName,
|
|
788
|
+
})}`,
|
|
789
|
+
);
|
|
790
|
+
return { outcome: 'dry_run', dry_run: data, exit_code: 0 };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (data.dry_run !== false) {
|
|
794
|
+
// The apply did not reach Hiveku as one (only a dry run came back).
|
|
795
|
+
console.log('Hiveku only showed what would change; nothing was rolled back. Check `hiveku-sync versions`.');
|
|
796
|
+
return { outcome: 'failed', result: data, exit_code: EXIT_FAILED };
|
|
797
|
+
}
|
|
798
|
+
if (data.noop) {
|
|
799
|
+
console.log(`${where} already matches ${targetName}. Nothing was changed.`);
|
|
800
|
+
return { outcome: 'noop', result: data, exit_code: 0 };
|
|
801
|
+
}
|
|
802
|
+
const newName = data.version?.name ? ` The new version is "${data.version.name}".` : '';
|
|
803
|
+
console.log(`Rolled ${where} back to ${targetName}.${newName}`);
|
|
804
|
+
if (data.auto_version?.name) console.log(` Changes that were not saved as a version were saved first, as "${data.auto_version.name}".`);
|
|
805
|
+
if (Array.isArray(data.concurrent_edits) && data.concurrent_edits.length) {
|
|
806
|
+
console.log(` ${data.concurrent_edits.length} file${data.concurrent_edits.length === 1 ? ' was' : 's were'} edited by someone else while this ran; check them.`);
|
|
807
|
+
}
|
|
808
|
+
console.log(' No published site changed. To publish this version: hiveku-sync deploy <development|staging|production>');
|
|
809
|
+
console.log(` To bring these files into your local folder: hiveku-sync pull-project ${target.slug}${targetBranch ? ` --branch ${targetBranch}` : ''}`);
|
|
810
|
+
return { outcome: 'rolled_back', result: data, exit_code: 0 };
|
|
811
|
+
}
|