@hiveku-apps/sync 0.3.6 → 0.5.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/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
- const diff = (
179
- await client.callToolJson('project_files_status', {
180
- project_id: projectId,
181
- local: await localCodeManifest(codeAll),
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
- // so every only_remote entry is a genuine code deletion. If a public/<subdir>/
192
- // image DOES show up here, it's a stale code_versions duplicate (the VANISH
193
- // cause) and deleting it is correct — project_file_delete only touches
194
- // code_versions, never the CDN asset we just uploaded.
195
- const codeToDelete = includeDeletes ? (diff.only_remote || []).map((c) => c.path) : [];
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 (codeToSave.length === 0 && codeToDelete.length === 0 && assetToUpload.length === 0) {
303
- console.log('Nothing to push — local is in sync with the deploy source.');
304
- if (deploy) return runDeployFor({ client, projectId, slug: resolvedSlug, environment: deploy, wait });
305
- return { skipped: true, plan };
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
- const failedPaths = [];
309
- let codeSaved = 0;
310
-
311
- // ── Code lane: batched, per-batch-verified bulk_save ──────────────────────
312
- const BATCH_FILES = 40;
313
- const BATCH_BYTES = 4_000_000; // well under the server's 20MB cap; sized for MCP-transport reliability
314
- let batch = [];
315
- let batchBytes = 0;
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
- for (const f of codeToSave) {
346
- if (batch.length >= BATCH_FILES || (batchBytes > 0 && batchBytes + f.content.length > BATCH_BYTES)) {
347
- await flush();
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
- batch.push(f);
351
- batchBytes += f.content.length;
399
+ return { skipped: true, plan, version, exit_code: exitCode };
352
400
  }
353
- await flush();
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', { project_id: projectId, file_path: p });
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(` ⚠ ${failedPaths.length} file(s) FAILED: ${failedPaths.slice(0, 10).join(', ')}${failedPaths.length > 10 ? '…' : ''}`);
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
- // ── Optional: record a Hiveku-native main commit (version-history marker).
398
- // Files are already saved via bulk_save; this snapshots the current state on
399
- // `main` so `hiveku-sync` history shows the push. GitHub not required.
400
- if (commit) {
401
- try {
402
- const c = (
403
- await client.callToolJson('project_vcs_commit', {
404
- project_id: projectId,
405
- message: commit,
406
- branch: branch || info.branch || undefined,
407
- deletedFiles: codeToDelete.length ? codeToDelete : undefined,
408
- })
409
- ).data;
410
- console.log(` committed on ${c?.branch_name || branch || 'main'} (${c?.checkpoint_hash ? c.checkpoint_hash.slice(0, 12) : c?.id || 'recorded'})`);
411
- } catch (e) {
412
- console.log(` ⚠ commit marker failed (files ARE saved): ${(e && e.message) || e}`);
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 result = { plan, code_saved: codeSaved, assets_uploaded: assetDone, deleted, failed: failedPaths };
426
- if (deploy) {
427
- const d = await runDeployFor({ client, projectId, slug: resolvedSlug, environment: deploy, wait });
428
- result.deploy = d;
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(' ⚠ no deployment_id to poll — check deploy_status manually.');
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(` ✓ deployed: ${url || '(url pending)'}`);
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(` ✗ deploy ${status}${dep?.error ? `: ${dep.error}` : ''}`);
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(` ⚠ still ${last || 'pending'} after ${Math.round(MAX_MS / 60000)}min — check deploy_status.`);
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
+ }