@hiveku-apps/sync 0.3.5 → 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 +121 -8
- package/bin/hiveku-sync.mjs +139 -25
- package/package.json +1 -1
- package/src/codex-prompt-tools.json +253 -0
- package/src/codex.mjs +245 -28
- 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/toml.mjs +666 -0
- package/src/versionName.mjs +569 -0
- package/src/versions.mjs +811 -0
package/src/push.mjs
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* pull-env project_secrets_list → .env.local with the same _DEV/_PROD/
|
|
14
14
|
* _STAGING resolution the preview machine uses.
|
|
15
15
|
*
|
|
16
|
+
* After a push whose files all landed, push saves them as ONE version of Your
|
|
17
|
+
* site (or of the branch it pushed to): project_vcs_commit with NO files and
|
|
18
|
+
* NO deletedFiles (versions.mjs versionAfterPush). --no-version skips it. Exit
|
|
19
|
+
* codes: 1 when files failed or the push was refused (no version, no deploy),
|
|
20
|
+
* 2 when every file IS saved but the version was not, 3 when the files (and
|
|
21
|
+
* the version) ARE saved and only the deploy failed.
|
|
22
|
+
*
|
|
16
23
|
* Why not just project_commit (the old push)? project_commit is the GitHub
|
|
17
24
|
* path (needs a connected repo) AND sends every file in one unbatched call.
|
|
18
25
|
* bulk_save works on every project, batches for transport reliability, and —
|
|
@@ -35,6 +42,33 @@ import {
|
|
|
35
42
|
writeProjectsManifest,
|
|
36
43
|
} from './projects.mjs';
|
|
37
44
|
import { writeFileAtomic, readEnv, loadDotenv } from './util.mjs';
|
|
45
|
+
import {
|
|
46
|
+
branchLabel,
|
|
47
|
+
checkedVersionName,
|
|
48
|
+
chooseVersionName,
|
|
49
|
+
commitRetryCommand,
|
|
50
|
+
describeChosenName,
|
|
51
|
+
EXIT_DEPLOY_FAILED,
|
|
52
|
+
EXIT_FAILED,
|
|
53
|
+
EXIT_VERSION_FAILED,
|
|
54
|
+
normalizeBranch,
|
|
55
|
+
reportVersion,
|
|
56
|
+
resolvePushBranch,
|
|
57
|
+
saveVersion,
|
|
58
|
+
statusWithOwnChanges,
|
|
59
|
+
tryReadVersionStatus,
|
|
60
|
+
versionAfterPush,
|
|
61
|
+
} from './versions.mjs';
|
|
62
|
+
import { saveCodeFiles } from './saveFiles.mjs';
|
|
63
|
+
import {
|
|
64
|
+
branchMovedSafeNote,
|
|
65
|
+
branchMovedSincePull,
|
|
66
|
+
deletablePaths,
|
|
67
|
+
deployTargetNote,
|
|
68
|
+
pathsAtRisk,
|
|
69
|
+
reportBranchMoved,
|
|
70
|
+
sharedAssetsNote,
|
|
71
|
+
} from './pushGuards.mjs';
|
|
38
72
|
|
|
39
73
|
const PROJECTS_DIR = 'projects';
|
|
40
74
|
|
|
@@ -158,7 +192,7 @@ async function remoteAssetMap(client, projectId) {
|
|
|
158
192
|
* files, the code diff (one status call), the asset diff (assets_list), and a
|
|
159
193
|
* summary plan.
|
|
160
194
|
*/
|
|
161
|
-
export async function computePushPlan({ client, projectId, folder, includeDeletes = false, noAssets = false, assetsAll = false }) {
|
|
195
|
+
export async function computePushPlan({ client, projectId, folder, includeDeletes = false, noAssets = false, assetsAll = false, branch = null }) {
|
|
162
196
|
// 1. Read + lane-classify every local file.
|
|
163
197
|
const rels = (await walkLocalFiles(folder)).map((abs) =>
|
|
164
198
|
relative(folder, abs).split(sep).join('/'),
|
|
@@ -175,24 +209,24 @@ export async function computePushPlan({ client, projectId, folder, includeDelete
|
|
|
175
209
|
// computed against the whole manifest, so chunking produces phantom
|
|
176
210
|
// deletions — the exact gotcha the momentum session hit). Only CODE files
|
|
177
211
|
// are sent, so binary assets never show up as false only_local.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
})
|
|
183
|
-
).data;
|
|
212
|
+
// On a branch the diff is against the branch's working tree.
|
|
213
|
+
const statusArgs = { project_id: projectId, local: await localCodeManifest(codeAll) };
|
|
214
|
+
if (branch) statusArgs.target = `branch:${branch}`;
|
|
215
|
+
const diff = (await client.callToolJson('project_files_status', statusArgs)).data;
|
|
184
216
|
const changedSet = new Set([
|
|
185
217
|
...(diff.changed || []).map((c) => c.path),
|
|
186
218
|
...(diff.only_local || []).map((c) => c.path),
|
|
187
219
|
]);
|
|
188
220
|
const codeToSave = codeAll.filter((f) => changedSet.has(f.path));
|
|
189
221
|
// only_remote = code-lane paths (builder_code_versions) missing locally —
|
|
190
|
-
// status never reports binary assets (those live in builder_project_assets)
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
222
|
+
// status never reports binary assets (those live in builder_project_assets).
|
|
223
|
+
// If a public/<subdir>/ image DOES show up here, it's a stale code_versions
|
|
224
|
+
// duplicate (the VANISH cause) and deleting it is correct —
|
|
225
|
+
// project_file_delete only touches code_versions, never the CDN asset we
|
|
226
|
+
// just uploaded. Paths this folder never holds (dot files, build folders,
|
|
227
|
+
// files Hiveku manages) are not deletions (pushGuards.mjs).
|
|
228
|
+
const { deletable, notMirrored } = deletablePaths(diff.only_remote || []);
|
|
229
|
+
const codeToDelete = includeDeletes ? deletable : [];
|
|
196
230
|
|
|
197
231
|
// 3. ASSET diff — asset-lane-aware, so unchanged images aren't re-sent.
|
|
198
232
|
const assetToUpload = [];
|
|
@@ -221,7 +255,7 @@ export async function computePushPlan({ client, projectId, folder, includeDelete
|
|
|
221
255
|
asset_unchanged: assetSkipped,
|
|
222
256
|
asset_skipped_lane: noAssets ? assetAll.length : 0,
|
|
223
257
|
};
|
|
224
|
-
return { codeAll, assetAll, diff, codeToSave, codeToDelete, assetToUpload, assetSkipped, plan };
|
|
258
|
+
return { codeAll, assetAll, diff, codeToSave, codeToDelete, remoteOnly: deletable, notMirrored, assetToUpload, assetSkipped, plan };
|
|
225
259
|
}
|
|
226
260
|
|
|
227
261
|
// Thin CLI wrapper: resolve the MCP client + project folder from cwd, then run
|
|
@@ -268,17 +302,40 @@ export async function pushProject({
|
|
|
268
302
|
assetsAll = false,
|
|
269
303
|
dryRun = false,
|
|
270
304
|
branch,
|
|
305
|
+
noVersion = false,
|
|
306
|
+
force = false,
|
|
307
|
+
retryDelayMs,
|
|
271
308
|
}) {
|
|
309
|
+
// These checks run before any tool call, so a refusal changes nothing.
|
|
310
|
+
// null = Your site (main).
|
|
311
|
+
const targetBranch = resolvePushBranch({ branch, info });
|
|
312
|
+
// A typed name is checked now: the builder keeps a hiveku-sync name as sent.
|
|
313
|
+
const typedName = noVersion ? undefined : checkedVersionName(commit);
|
|
314
|
+
const deployEnv = deploy ? checkedEnv(deploy) : null;
|
|
272
315
|
const {
|
|
273
316
|
codeAll,
|
|
274
317
|
assetAll,
|
|
318
|
+
diff,
|
|
275
319
|
codeToSave,
|
|
276
320
|
codeToDelete,
|
|
277
321
|
assetToUpload,
|
|
278
322
|
assetSkipped,
|
|
279
323
|
plan: basePlan,
|
|
280
|
-
} = await computePushPlan({ client, projectId, folder, includeDeletes, noAssets, assetsAll });
|
|
281
|
-
const plan = { project: resolvedSlug, ...basePlan };
|
|
324
|
+
} = await computePushPlan({ client, projectId, folder, includeDeletes, noAssets, assetsAll, branch: targetBranch });
|
|
325
|
+
const plan = { project: resolvedSlug, ...basePlan, branch: targetBranch || 'main' };
|
|
326
|
+
const changedPaths = new Set((diff?.changed || []).map((c) => c.path));
|
|
327
|
+
// What this push changes, for the version name when Hiveku cannot list what is waiting.
|
|
328
|
+
const ownChanges = (failedSet = new Set()) => ({
|
|
329
|
+
added: codeToSave.filter((f) => !changedPaths.has(f.path) && !failedSet.has(f.path)).map((f) => f.path),
|
|
330
|
+
modified: codeToSave.filter((f) => changedPaths.has(f.path) && !failedSet.has(f.path)).map((f) => f.path),
|
|
331
|
+
removed: codeToDelete,
|
|
332
|
+
});
|
|
333
|
+
// Someone saved on the branch since the pull: the files this push would
|
|
334
|
+
// overwrite or delete may hold their work.
|
|
335
|
+
const moved = branchMovedSincePull({ info, targetBranch, diff });
|
|
336
|
+
const atRisk = moved ? pathsAtRisk({ diff, codeToDelete }) : [];
|
|
337
|
+
const movedNote = moved && atRisk.length === 0 ? branchMovedSafeNote(targetBranch) : null;
|
|
338
|
+
const assetsNote = sharedAssetsNote(targetBranch, assetToUpload.length);
|
|
282
339
|
|
|
283
340
|
if (dryRun) {
|
|
284
341
|
console.log('[dry run] push plan:');
|
|
@@ -286,6 +343,24 @@ export async function pushProject({
|
|
|
286
343
|
console.log(` code delete: ${plan.code_delete}${includeDeletes ? '' : ' (pass --include-deletes to apply)'}`);
|
|
287
344
|
console.log(` asset upload: ${plan.asset_upload}`);
|
|
288
345
|
console.log(` asset skip: ${plan.asset_unchanged} unchanged${noAssets ? `, ${assetAll.length} lane-skipped (--no-assets)` : ''}`);
|
|
346
|
+
console.log(` saves to: ${branchLabel(targetBranch)}`);
|
|
347
|
+
if (noVersion) {
|
|
348
|
+
console.log(' version: none (--no-version)');
|
|
349
|
+
} else if (codeToSave.length === 0 && codeToDelete.length === 0 && !typedName) {
|
|
350
|
+
console.log(' version: none (no project files change; images, fonts and videos are not part of versions)');
|
|
351
|
+
} else {
|
|
352
|
+
// A version holds everything waiting, not only this push: name it from both.
|
|
353
|
+
const status = typedName ? null : await tryReadVersionStatus({ client, projectId, branch: targetBranch });
|
|
354
|
+
const choice = chooseVersionName({ typedName, status: statusWithOwnChanges(status, ownChanges()), own: ownChanges() });
|
|
355
|
+
console.log(` version: ${describeChosenName(choice)} (after every file is saved)`);
|
|
356
|
+
}
|
|
357
|
+
if (assetsNote) console.log(` ${assetsNote}`);
|
|
358
|
+
if (deployEnv) {
|
|
359
|
+
const note = deployTargetNote(targetBranch, deployEnv);
|
|
360
|
+
if (note) console.log(` ${note}`);
|
|
361
|
+
}
|
|
362
|
+
if (atRisk.length) reportBranchMoved({ branch: targetBranch, slug: resolvedSlug, paths: atRisk, force, dryRun: true });
|
|
363
|
+
else if (movedNote) console.log(` ${movedNote}`);
|
|
289
364
|
if (codeToSave.length) {
|
|
290
365
|
console.log('\n files to save:');
|
|
291
366
|
for (const f of codeToSave.slice(0, 30)) console.log(` M ${f.path}`);
|
|
@@ -296,63 +371,54 @@ export async function pushProject({
|
|
|
296
371
|
for (const f of assetToUpload.slice(0, 20)) console.log(` A ${f.path}`);
|
|
297
372
|
if (assetToUpload.length > 20) console.log(` ... and ${assetToUpload.length - 20} more`);
|
|
298
373
|
}
|
|
299
|
-
return { dryRun: true, plan };
|
|
374
|
+
return { dryRun: true, plan, ...(moved ? { branch_moved: moved, at_risk: atRisk } : {}) };
|
|
300
375
|
}
|
|
301
376
|
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
|
|
377
|
+
if (atRisk.length) {
|
|
378
|
+
reportBranchMoved({ branch: targetBranch, slug: resolvedSlug, paths: atRisk, force, dryRun: false });
|
|
379
|
+
if (!force) return { refused: 'branch_moved', plan, branch_moved: moved, at_risk: atRisk, exit_code: EXIT_FAILED };
|
|
380
|
+
} else if (movedNote) {
|
|
381
|
+
console.log(` ${movedNote}`);
|
|
306
382
|
}
|
|
383
|
+
const retryCommand = commitRetryCommand({ slug: resolvedSlug, branch: targetBranch, recordedBranch: info.branch, typedName });
|
|
307
384
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
let batchNo = 0;
|
|
317
|
-
const flush = async () => {
|
|
318
|
-
if (batch.length === 0) return;
|
|
319
|
-
batchNo += 1;
|
|
320
|
-
const files = batch.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding }));
|
|
321
|
-
const label = batch;
|
|
322
|
-
batch = [];
|
|
323
|
-
batchBytes = 0;
|
|
324
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
325
|
-
let r;
|
|
326
|
-
try {
|
|
327
|
-
r = (
|
|
328
|
-
await client.callToolJson('project_files_bulk_save', {
|
|
329
|
-
project_id: projectId,
|
|
330
|
-
files,
|
|
331
|
-
commit_message: commit || 'hiveku-sync: push local changes',
|
|
332
|
-
})
|
|
333
|
-
).data;
|
|
334
|
-
} catch (e) {
|
|
335
|
-
if (attempt >= 2) failedPaths.push(...label.map((f) => f.path));
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const succeeded = r?.summary?.succeeded ?? files.length;
|
|
339
|
-
codeSaved += succeeded;
|
|
340
|
-
if (succeeded === files.length) return;
|
|
341
|
-
const bad = (r?.results || []).filter((x) => x.ok === false).map((x) => x.path);
|
|
342
|
-
if (attempt >= 2) failedPaths.push(...(bad.length ? bad : label.map((f) => f.path)));
|
|
385
|
+
if (codeToSave.length === 0 && codeToDelete.length === 0 && assetToUpload.length === 0) {
|
|
386
|
+
console.log(`Nothing to push: this folder matches ${branchLabel(targetBranch)}.`);
|
|
387
|
+
// Nothing of this push to name; a typed name still saves what is waiting
|
|
388
|
+
// (e.g. after an earlier push whose version did not save).
|
|
389
|
+
let version = null;
|
|
390
|
+
if (typedName) {
|
|
391
|
+
version = await saveVersion({ client, projectId, branch: targetBranch, name: typedName, retryDelayMs });
|
|
392
|
+
reportVersion(version, { branch: targetBranch, filesSaved: false, retryCommand });
|
|
343
393
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
console.log(` code ${codeSaved}/${codeToSave.length}`);
|
|
394
|
+
const exitCode = version?.outcome === 'failed' ? EXIT_VERSION_FAILED : 0;
|
|
395
|
+
if (deployEnv) {
|
|
396
|
+
const d = await deployAfterPush({ client, projectId, slug: resolvedSlug, environment: deployEnv, wait, branch: targetBranch });
|
|
397
|
+
return { ...d.result, skipped: true, plan, version, deploy: d.result, exit_code: d.failed && !exitCode ? EXIT_DEPLOY_FAILED : exitCode };
|
|
349
398
|
}
|
|
350
|
-
|
|
351
|
-
batchBytes += f.content.length;
|
|
399
|
+
return { skipped: true, plan, version, exit_code: exitCode };
|
|
352
400
|
}
|
|
353
|
-
|
|
401
|
+
|
|
402
|
+
// Said before the first write, while --no-assets can still leave them out.
|
|
403
|
+
if (assetsNote) console.log(` ${assetsNote}`);
|
|
404
|
+
|
|
405
|
+
// ── Code lane: batched bulk_save, each batch checked (saveFiles.mjs) ──────
|
|
406
|
+
const code = await saveCodeFiles({
|
|
407
|
+
client,
|
|
408
|
+
projectId,
|
|
409
|
+
files: codeToSave,
|
|
410
|
+
branch: targetBranch,
|
|
411
|
+
// A note on each file's row, never a version (the version is saved once, below).
|
|
412
|
+
note: commit || 'hiveku-sync: push local changes',
|
|
413
|
+
log: (line) => console.log(line),
|
|
414
|
+
});
|
|
415
|
+
const failedPaths = [...code.failed];
|
|
416
|
+
const codeSaved = code.saved;
|
|
417
|
+
// The branch tree this push leaves behind (every branch write answers it).
|
|
418
|
+
let branchEtag = code.workingTreeEtag;
|
|
354
419
|
|
|
355
420
|
// ── Asset lane: assets_upload, bounded concurrency, retry once ────────────
|
|
421
|
+
// The asset store is project-wide: there is no per-branch copy.
|
|
356
422
|
let assetDone = 0;
|
|
357
423
|
await runLimited(assetToUpload, 5, async (f) => {
|
|
358
424
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
@@ -377,7 +443,13 @@ export async function pushProject({
|
|
|
377
443
|
let deleted = 0;
|
|
378
444
|
for (const p of codeToDelete) {
|
|
379
445
|
try {
|
|
380
|
-
await client.callToolJson('project_file_delete', {
|
|
446
|
+
const answer = await client.callToolJson('project_file_delete', {
|
|
447
|
+
project_id: projectId,
|
|
448
|
+
file_path: p,
|
|
449
|
+
...(targetBranch ? { branch: targetBranch } : {}),
|
|
450
|
+
});
|
|
451
|
+
const etag = answer?.working_tree_etag ?? answer?.data?.working_tree_etag;
|
|
452
|
+
if (typeof etag === 'string' && etag) branchEtag = etag;
|
|
381
453
|
deleted += 1;
|
|
382
454
|
} catch {
|
|
383
455
|
failedPaths.push(p);
|
|
@@ -390,46 +462,106 @@ export async function pushProject({
|
|
|
390
462
|
(deleted ? `, ${deleted} deletion${deleted !== 1 ? 's' : ''}` : '') +
|
|
391
463
|
(assetSkipped ? ` (${assetSkipped} asset${assetSkipped !== 1 ? 's' : ''} unchanged)` : ''),
|
|
392
464
|
);
|
|
465
|
+
const failedSet = new Set(failedPaths);
|
|
393
466
|
if (failedPaths.length) {
|
|
394
|
-
console.log(
|
|
467
|
+
console.log(
|
|
468
|
+
` ${failedPaths.length} file${failedPaths.length !== 1 ? 's' : ''} did NOT save: ` +
|
|
469
|
+
`${failedPaths.slice(0, 10).join(', ')}${failedPaths.length > 10 ? '…' : ''}`,
|
|
470
|
+
);
|
|
471
|
+
console.log(' Every other file IS saved. Push again to retry the ones that failed.');
|
|
395
472
|
}
|
|
396
473
|
|
|
397
|
-
// ──
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
474
|
+
// ── Version: ONE version for this push, only when every file landed ───────
|
|
475
|
+
// project_vcs_commit with NO files and NO deletedFiles: on Your site it
|
|
476
|
+
// saves everything not yet in a version (the deletions above are already
|
|
477
|
+
// applied, and re-sending them would delete again); on a branch it saves
|
|
478
|
+
// the branch's working tree. 409 nothing_to_commit is fine.
|
|
479
|
+
let version = null;
|
|
480
|
+
if (failedPaths.length) {
|
|
481
|
+
console.log(' Not saved as a version, because some files did not save.');
|
|
482
|
+
} else if (noVersion) {
|
|
483
|
+
console.log(' Not saved as a version (--no-version). `hiveku-sync commit` saves one when you are ready.');
|
|
484
|
+
} else if (codeSaved + deleted === 0 && !typedName) {
|
|
485
|
+
console.log(' Images, fonts and videos are not part of versions, so there was nothing to save as a version.');
|
|
486
|
+
} else {
|
|
487
|
+
// Named from everything waiting (other writers' changes too), not only this push.
|
|
488
|
+
version = await versionAfterPush({
|
|
489
|
+
client,
|
|
490
|
+
projectId,
|
|
491
|
+
branch: targetBranch,
|
|
492
|
+
typedName,
|
|
493
|
+
own: ownChanges(failedSet),
|
|
494
|
+
slug: resolvedSlug,
|
|
495
|
+
recordedBranch: info.branch,
|
|
496
|
+
retryDelayMs,
|
|
497
|
+
});
|
|
414
498
|
}
|
|
415
499
|
|
|
416
|
-
// Update manifest timestamps
|
|
500
|
+
// Update manifest timestamps, and on the folder's own branch the tree this
|
|
501
|
+
// push left (so the next push can tell whether someone saved since). A
|
|
502
|
+
// branch someone else saved on keeps the pull's etag until the folder is
|
|
503
|
+
// pulled again, unless --force said to overwrite their work.
|
|
417
504
|
const manifest = await readProjectsManifest(baseDir);
|
|
418
505
|
manifest.projects = manifest.projects || {};
|
|
506
|
+
const followsThisBranch = targetBranch && targetBranch === normalizeBranch(info.branch) && (!moved || force);
|
|
419
507
|
manifest.projects[resolvedSlug] = {
|
|
420
508
|
...(manifest.projects[resolvedSlug] || { project_id: projectId }),
|
|
421
509
|
last_push_at: new Date().toISOString(),
|
|
510
|
+
...(version?.outcome === 'saved' && version.version?.id ? { last_version_id: version.version.id } : {}),
|
|
511
|
+
...(followsThisBranch && branchEtag ? { working_tree_etag: branchEtag } : {}),
|
|
422
512
|
};
|
|
423
513
|
await writeProjectsManifest(baseDir, manifest);
|
|
424
514
|
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
515
|
+
const exitCode = failedPaths.length ? EXIT_FAILED : version?.outcome === 'failed' ? EXIT_VERSION_FAILED : 0;
|
|
516
|
+
const result = {
|
|
517
|
+
plan,
|
|
518
|
+
code_saved: codeSaved,
|
|
519
|
+
assets_uploaded: assetDone,
|
|
520
|
+
deleted,
|
|
521
|
+
failed: failedPaths,
|
|
522
|
+
version,
|
|
523
|
+
exit_code: exitCode,
|
|
524
|
+
};
|
|
525
|
+
if (deployEnv) {
|
|
526
|
+
if (failedPaths.length) {
|
|
527
|
+
console.log(`Not deploying to ${deployEnv}: publish only after every file has saved.`);
|
|
528
|
+
} else {
|
|
529
|
+
// The version (if any) is saved first, so the deploy ships a version.
|
|
530
|
+
const d = await deployAfterPush({ client, projectId, slug: resolvedSlug, environment: deployEnv, wait, branch: targetBranch });
|
|
531
|
+
result.deploy = d.result;
|
|
532
|
+
// A version that did not save (2) says more than a deploy that did not finish.
|
|
533
|
+
if (d.failed && !result.exit_code) result.exit_code = EXIT_DEPLOY_FAILED;
|
|
534
|
+
}
|
|
429
535
|
}
|
|
430
536
|
return result;
|
|
431
537
|
}
|
|
432
538
|
|
|
539
|
+
/** A --deploy tier, checked before anything is written (a typo must not wait until after the push). */
|
|
540
|
+
function checkedEnv(environment) {
|
|
541
|
+
const env = normalizeEnv(environment);
|
|
542
|
+
if (!['development', 'staging', 'production'].includes(env)) {
|
|
543
|
+
throw new Error(`Invalid environment "${environment}": use development | staging | production. Nothing was changed.`);
|
|
544
|
+
}
|
|
545
|
+
return env;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The deploy at the end of a push. A failed deploy is not a failed push: the
|
|
550
|
+
* files (and the version) ARE saved, so it says so and exits 3, never 1
|
|
551
|
+
* (which means files did not save).
|
|
552
|
+
*/
|
|
553
|
+
async function deployAfterPush({ client, projectId, slug, environment, wait, branch }) {
|
|
554
|
+
const note = deployTargetNote(branch, environment);
|
|
555
|
+
if (note) console.log(` ${note}`);
|
|
556
|
+
try {
|
|
557
|
+
return { result: await runDeployFor({ client, projectId, slug, environment, wait }), failed: false };
|
|
558
|
+
} catch (err) {
|
|
559
|
+
console.log(` The deploy to ${environment} did not finish: ${err.message}.`);
|
|
560
|
+
console.log(` Your files ARE saved on Hiveku (and the version, if one was saved). Deploy again with: hiveku-sync deploy ${environment} ${slug}${wait ? ' --wait' : ''}`);
|
|
561
|
+
return { result: { environment, ok: false, error: err.message, ...(err.deploy || {}) }, failed: true };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
433
565
|
// ── Deploy ──────────────────────────────────────────────────────────────────
|
|
434
566
|
|
|
435
567
|
const DEPLOY_SUCCESS = new Set(['completed', 'ready', 'succeeded', 'success', 'deployed', 'live']);
|
|
@@ -456,13 +588,17 @@ async function runDeployFor({ client, projectId, slug, environment, wait }) {
|
|
|
456
588
|
const startUrl = started?.url || started?.preview_url;
|
|
457
589
|
console.log(` deployment_id: ${deploymentId || '(none returned)'}`);
|
|
458
590
|
if (startUrl) console.log(` url: ${startUrl}`);
|
|
591
|
+
// Which version this deploy ships, in the server's own plain words (a
|
|
592
|
+
// deploy of Your site saves waiting changes as a version first).
|
|
593
|
+
if (typeof started?.note === 'string' && started.note.trim()) console.log(` ${started.note.trim()}`);
|
|
594
|
+
const shipped = started?.vcs_commit_id ? { version_id: started.vcs_commit_id } : {};
|
|
459
595
|
|
|
460
596
|
if (!wait) {
|
|
461
597
|
console.log(' (not waiting — pass --wait to poll to completion)');
|
|
462
|
-
return { deployment_id: deploymentId, environment: env, url: startUrl, waited: false };
|
|
598
|
+
return { deployment_id: deploymentId, environment: env, url: startUrl, waited: false, ...shipped };
|
|
463
599
|
}
|
|
464
600
|
if (!deploymentId) {
|
|
465
|
-
console.log('
|
|
601
|
+
console.log(' No deployment id came back to follow: check it with `deploy_status`.');
|
|
466
602
|
return { environment: env, waited: false };
|
|
467
603
|
}
|
|
468
604
|
|
|
@@ -486,11 +622,11 @@ async function runDeployFor({ client, projectId, slug, environment, wait }) {
|
|
|
486
622
|
}
|
|
487
623
|
if (DEPLOY_SUCCESS.has(status)) {
|
|
488
624
|
const url = dep?.url || dep?.preview_url || startUrl;
|
|
489
|
-
console.log(`
|
|
490
|
-
return { deployment_id: deploymentId, environment: env, status, url, waited: true, ok: true };
|
|
625
|
+
console.log(` Deployed: ${url || '(address not ready yet)'}`);
|
|
626
|
+
return { deployment_id: deploymentId, environment: env, status, url, waited: true, ok: true, ...shipped };
|
|
491
627
|
}
|
|
492
628
|
if (DEPLOY_FAILURE.has(status)) {
|
|
493
|
-
console.log(`
|
|
629
|
+
console.log(` Deploy ${status}${dep?.error ? `: ${dep.error}` : ''}`);
|
|
494
630
|
if (dep?.build_logs) {
|
|
495
631
|
const tail = String(dep.build_logs).split('\n').slice(-15).join('\n');
|
|
496
632
|
console.log(' --- build log (tail) ---\n' + tail);
|
|
@@ -500,7 +636,7 @@ async function runDeployFor({ client, projectId, slug, environment, wait }) {
|
|
|
500
636
|
throw err;
|
|
501
637
|
}
|
|
502
638
|
}
|
|
503
|
-
console.log(`
|
|
639
|
+
console.log(` Still ${last || 'pending'} after ${Math.round(MAX_MS / 60000)} min: check it with \`deploy_status\`.`);
|
|
504
640
|
return { deployment_id: deploymentId, environment: env, status: last, waited: true, ok: false };
|
|
505
641
|
}
|
|
506
642
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks a push makes before it writes anything, and the notes it prints
|
|
3
|
+
* where a person can still act on them.
|
|
4
|
+
*
|
|
5
|
+
* - deletablePaths: `--include-deletes` deletes only files this folder
|
|
6
|
+
* could hold. The local walk never lists dot files (.gitignore,
|
|
7
|
+
* .eslintrc.json, .ai/memory.md), build and tool folders, or the pulled
|
|
8
|
+
* account memory, and a pull does not bring down the files Hiveku manages
|
|
9
|
+
* itself (soul.md, connected_tools.md, data/marketing/...). Those always
|
|
10
|
+
* read as "only on Hiveku", which is not a deletion.
|
|
11
|
+
* - branchMovedSincePull: on a branch, the working_tree_etag recorded at
|
|
12
|
+
* pull time against the one project_files_status reports now. When they
|
|
13
|
+
* differ, someone saved on the branch since the pull, and a push would
|
|
14
|
+
* overwrite their files with this folder's older copies.
|
|
15
|
+
* - sharedAssetsNote / deployTargetNote: images, fonts and videos are one
|
|
16
|
+
* store shared by Your site and every branch, and deploy_site publishes
|
|
17
|
+
* what a tier is set to publish (Your site unless the tier is bound to a
|
|
18
|
+
* branch), not the branch just pushed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { localWalkCanList } from './projects.mjs';
|
|
22
|
+
import { branchLabel, normalizeBranch } from './versions.mjs';
|
|
23
|
+
import { isServerManagedFile } from './versionName.mjs';
|
|
24
|
+
|
|
25
|
+
/** Split only_remote paths into the ones a push may delete and the ones this folder never holds. */
|
|
26
|
+
export function deletablePaths(onlyRemote = []) {
|
|
27
|
+
const deletable = [];
|
|
28
|
+
const notMirrored = [];
|
|
29
|
+
for (const entry of onlyRemote) {
|
|
30
|
+
const path = typeof entry === 'string' ? entry : entry?.path;
|
|
31
|
+
if (typeof path !== 'string' || !path) continue;
|
|
32
|
+
if (localWalkCanList(path) && !isServerManagedFile(path.replace(/^\/+/, ''))) deletable.push(path);
|
|
33
|
+
else notMirrored.push(path);
|
|
34
|
+
}
|
|
35
|
+
return { deletable, notMirrored };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Null when the branch is where the pull left it (or it cannot be told);
|
|
40
|
+
* else { recorded, current }. Only for a push to the branch the folder was
|
|
41
|
+
* pulled from: another branch's etag says nothing about this one.
|
|
42
|
+
*/
|
|
43
|
+
export function branchMovedSincePull({ info = {}, targetBranch, diff }) {
|
|
44
|
+
if (!targetBranch || normalizeBranch(info.branch) !== targetBranch) return null;
|
|
45
|
+
const recorded = typeof info.working_tree_etag === 'string' && info.working_tree_etag ? info.working_tree_etag : null;
|
|
46
|
+
const current = typeof diff?.basis?.working_tree_etag === 'string' && diff.basis.working_tree_etag ? diff.basis.working_tree_etag : null;
|
|
47
|
+
if (!recorded || !current || recorded === current) return null;
|
|
48
|
+
return { recorded, current };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The files a push would overwrite or delete on a branch someone else saved on. */
|
|
52
|
+
export function pathsAtRisk({ diff, codeToDelete = [] }) {
|
|
53
|
+
return [...(diff?.changed || []).map((c) => c.path), ...codeToDelete];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Print why a push to a moved branch is refused (or, with --force, goes ahead). */
|
|
57
|
+
export function reportBranchMoved({ branch, slug, paths, force, dryRun }) {
|
|
58
|
+
const shown = paths.slice(0, 15);
|
|
59
|
+
console.log(
|
|
60
|
+
`Someone saved on ${branchLabel(branch)} since this folder was pulled, so these files may hold their work` +
|
|
61
|
+
(paths.length ? ':' : '.'),
|
|
62
|
+
);
|
|
63
|
+
for (const path of shown) console.log(` ${path}`);
|
|
64
|
+
if (paths.length > shown.length) console.log(` ... and ${paths.length - shown.length} more`);
|
|
65
|
+
if (force) {
|
|
66
|
+
console.log(` --force: ${dryRun ? 'the push would overwrite' : 'overwriting'} them with this folder's copies.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(
|
|
70
|
+
`${dryRun ? 'A push would be refused' : 'Nothing was changed'}. To keep their work, copy your edits somewhere safe, pull again ` +
|
|
71
|
+
`(hiveku-sync pull-project ${slug} --branch ${branch}; it replaces this folder's copies), then make your edits again ` +
|
|
72
|
+
"and push. To overwrite their work with this folder's copies, push with --force.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Said when the branch moved but this push changes none of the files that differ from it. */
|
|
77
|
+
export function branchMovedSafeNote(branch) {
|
|
78
|
+
return (
|
|
79
|
+
`Note: someone saved on ${branchLabel(branch)} since this folder was pulled. This push changes none of the files ` +
|
|
80
|
+
'that differ from it; pull again to get their work.'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Said before anything is written (and in a dry run) when a branch push uploads images, fonts or videos. */
|
|
85
|
+
export function sharedAssetsNote(branch, count) {
|
|
86
|
+
if (!branch || !count) return null;
|
|
87
|
+
return (
|
|
88
|
+
`Note: images, fonts and videos are shared by Your site and every branch, so the ${count} this push uploads ` +
|
|
89
|
+
`change them everywhere, not only on ${branchLabel(branch)}. Pass --no-assets to leave them out.`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Said when a branch push also deploys: the tier publishes what it is set to publish. */
|
|
94
|
+
export function deployTargetNote(branch, environment) {
|
|
95
|
+
if (!branch) return null;
|
|
96
|
+
return (
|
|
97
|
+
`Note: deploying to ${environment} publishes what ${environment} is set to publish: Your site, unless ` +
|
|
98
|
+
`${environment} is bound to ${branchLabel(branch)}. It does not publish the branch just because this push went to it.`
|
|
99
|
+
);
|
|
100
|
+
}
|