@hiveku-apps/sync 0.1.0 → 0.3.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 ADDED
@@ -0,0 +1,618 @@
1
+ /**
2
+ * `hiveku-sync` deploy-lane commands — the agent-native push/deploy/pull-env
3
+ * loop. This is the CLI equivalent of the VS Code extension's Source Control
4
+ * "Push", so a headless agent (Claude Code in a terminal) gets the SAME proven
5
+ * mechanics without hand-rolling an MCP client:
6
+ *
7
+ * push Save local changes to the DEPLOY SOURCE (builder_code_versions),
8
+ * lane-routed: CDN-servable binary (images/fonts/video under
9
+ * public/<subdir>/) → assets_upload (S3/CDN); everything else →
10
+ * batched, per-batch-verified project_files_bulk_save. This is what
11
+ * deploy_site actually reads — NOT project_commit's GitHub branch.
12
+ * deploy deploy_site + poll deploy_get to a terminal status (--wait).
13
+ * pull-env project_secrets_list → .env.local with the same _DEV/_PROD/
14
+ * _STAGING resolution the preview machine uses.
15
+ *
16
+ * Why not just project_commit (the old push)? project_commit is the GitHub
17
+ * path (needs a connected repo) AND sends every file in one unbatched call.
18
+ * bulk_save works on every project, batches for transport reliability, and —
19
+ * critically — never puts an image in builder_code_versions where it renders
20
+ * in the Fly preview but VANISHES on deploy (asset-build-bypass excludes it).
21
+ */
22
+
23
+ import { createHash } from 'node:crypto';
24
+ import { existsSync } from 'node:fs';
25
+ import { readFile } from 'node:fs/promises';
26
+ import { join, relative, sep } from 'node:path';
27
+
28
+ import {
29
+ clientFromCwd,
30
+ findProjectFolder,
31
+ resolveProject,
32
+ walkLocalFiles,
33
+ readProjectsManifest,
34
+ writeProjectsManifest,
35
+ } from './projects.mjs';
36
+ import { writeFileAtomic, readEnv, loadDotenv } from './util.mjs';
37
+
38
+ const PROJECTS_DIR = 'projects';
39
+
40
+ // ── Lane classification — mirrors the server's asset-build-bypass rule
41
+ // (hiveku_builder/src/lib/deployment/asset-build-bypass.ts). A file resolves
42
+ // into a public/ SUBDIRECTORY (public/<subdir>/...) → the CDN serves it and the
43
+ // build must NOT bundle it, so it belongs in the asset lane. src/ assets and
44
+ // public/ ROOT files (favicon.ico, robots.txt) stay code-lane. The BINARY guard
45
+ // is added on top because assets_upload rejects code MIME types — a text file
46
+ // under public/<subdir>/ (a .json/.html) still goes code-lane. This exactly
47
+ // matches the VS Code extension's verified predicate (scm.ts push()).
48
+ export function resolveAssetBuildPath(filePath) {
49
+ const clean = filePath.replace(/^\//, '');
50
+ return clean.startsWith('src/') || clean.startsWith('public/')
51
+ ? clean
52
+ : `public/${clean.replace(/^public\//, '')}`;
53
+ }
54
+
55
+ export function isCdnServableAssetPath(filePath) {
56
+ return /^public\/[^/]+\//.test(resolveAssetBuildPath(filePath));
57
+ }
58
+
59
+ /**
60
+ * The actual push routing decision: 'asset' → assets_upload (S3/CDN), 'code' →
61
+ * bulk_save. A file goes to the asset lane ONLY when it is BINARY *and* resolves
62
+ * under public/<subdir>/. The binary gate matters because assets_upload rejects
63
+ * code MIME types — a text file under public/<subdir>/ (a .json/.html) and every
64
+ * source file stays code-lane even though its path may resolve under public/.
65
+ */
66
+ export function laneFor(filePath, encoding) {
67
+ return encoding === 'base64' && isCdnServableAssetPath(filePath) ? 'asset' : 'code';
68
+ }
69
+
70
+ /** Cheap, robust binary sniff: a NUL in the first 8KB, or a lossy utf-8 decode. */
71
+ export function looksBinary(buf) {
72
+ const n = Math.min(buf.length, 8000);
73
+ for (let i = 0; i < n; i++) {
74
+ if (buf[i] === 0) return true;
75
+ }
76
+ return buf.toString('utf8').includes('�');
77
+ }
78
+
79
+ function sha256Hex(str) {
80
+ return createHash('sha256').update(str).digest('hex');
81
+ }
82
+
83
+ /** Read one file and shape it for a save/commit, choosing utf-8 vs base64. */
84
+ async function readForPush(root, rel) {
85
+ const buf = await readFile(join(root, rel));
86
+ const binary = looksBinary(buf);
87
+ return {
88
+ path: rel,
89
+ content: binary ? buf.toString('base64') : buf.toString('utf8'),
90
+ encoding: binary ? 'base64' : 'utf-8',
91
+ bytes: buf.length,
92
+ };
93
+ }
94
+
95
+ // Concurrency-bounded map (no deps). Preserves input order in results.
96
+ async function runLimited(items, limit, fn) {
97
+ const results = new Array(items.length);
98
+ let next = 0;
99
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
100
+ while (true) {
101
+ const i = next++;
102
+ if (i >= items.length) return;
103
+ results[i] = await fn(items[i], i);
104
+ }
105
+ });
106
+ await Promise.all(workers);
107
+ return results;
108
+ }
109
+
110
+ /** Resolve the account baseDir + project folder the same way status/push do. */
111
+ function resolveBaseDir(cwd = process.cwd()) {
112
+ const idx = cwd.indexOf(`${sep}${PROJECTS_DIR}${sep}`);
113
+ return idx > -1 ? cwd.slice(0, idx) : cwd;
114
+ }
115
+
116
+ async function localCodeManifest(codeFiles) {
117
+ // codeFiles already read (utf-8). sha256 of the content string, matching the
118
+ // server's project_files_status hashing (and hiveku-sync's existing status).
119
+ return codeFiles.map((f) => ({ path: f.path, sha256: sha256Hex(f.content) }));
120
+ }
121
+
122
+ // ── assets_list → { resolvedPath -> byteSize } so we can skip re-uploading
123
+ // unchanged images instead of flagging all 468 as only_local (the noise the
124
+ // momentum session hit). assets_upload mirrors non-public paths under public/,
125
+ // so we compare on the resolved public/ form. Size is the change signal —
126
+ // assets_list doesn't expose a content hash, and a same-size same-path asset is
127
+ // treated as unchanged (documented heuristic; --assets-all forces re-upload).
128
+ async function remoteAssetMap(client, projectId) {
129
+ const map = new Map();
130
+ let page = 1;
131
+ const limit = 200;
132
+ while (true) {
133
+ let result;
134
+ try {
135
+ result = await client.callToolJson('assets_list', { project_id: projectId, page, limit });
136
+ } catch {
137
+ break;
138
+ }
139
+ const batch = result?.data || result?.assets || [];
140
+ for (const a of batch) {
141
+ const p = a.file_path || a.path;
142
+ if (!p) continue;
143
+ const size = a.file_size ?? a.size ?? a.bytes ?? null;
144
+ map.set(resolveAssetBuildPath(p), size);
145
+ }
146
+ const totalPages =
147
+ result?.pagination?.total_pages ?? result?.pagination?.pages ?? 1;
148
+ if (batch.length === 0 || page >= totalPages) break;
149
+ page += 1;
150
+ }
151
+ return map;
152
+ }
153
+
154
+ /**
155
+ * Compute what a push WOULD do — the deploy-source-accurate, asset-lane-aware
156
+ * diff shared by `push` and `status`. No writes. Returns the classified local
157
+ * files, the code diff (one status call), the asset diff (assets_list), and a
158
+ * summary plan.
159
+ */
160
+ export async function computePushPlan({ client, projectId, folder, includeDeletes = false, noAssets = false, assetsAll = false }) {
161
+ // 1. Read + lane-classify every local file.
162
+ const rels = (await walkLocalFiles(folder)).map((abs) =>
163
+ relative(folder, abs).split(sep).join('/'),
164
+ );
165
+ const codeAll = [];
166
+ const assetAll = [];
167
+ for (const rel of rels) {
168
+ const f = await readForPush(folder, rel);
169
+ if (laneFor(rel, f.encoding) === 'asset') assetAll.push(f);
170
+ else codeAll.push(f);
171
+ }
172
+
173
+ // 2. CODE diff — ONE project_files_status call (never chunked: only_remote is
174
+ // computed against the whole manifest, so chunking produces phantom
175
+ // deletions — the exact gotcha the momentum session hit). Only CODE files
176
+ // are sent, so binary assets never show up as false only_local.
177
+ const diff = (
178
+ await client.callToolJson('project_files_status', {
179
+ project_id: projectId,
180
+ local: await localCodeManifest(codeAll),
181
+ })
182
+ ).data;
183
+ const changedSet = new Set([
184
+ ...(diff.changed || []).map((c) => c.path),
185
+ ...(diff.only_local || []).map((c) => c.path),
186
+ ]);
187
+ const codeToSave = codeAll.filter((f) => changedSet.has(f.path));
188
+ // only_remote = code-lane paths (builder_code_versions) missing locally —
189
+ // status never reports binary assets (those live in builder_project_assets),
190
+ // so every only_remote entry is a genuine code deletion. If a public/<subdir>/
191
+ // image DOES show up here, it's a stale code_versions duplicate (the VANISH
192
+ // cause) and deleting it is correct — project_file_delete only touches
193
+ // code_versions, never the CDN asset we just uploaded.
194
+ const codeToDelete = includeDeletes ? (diff.only_remote || []).map((c) => c.path) : [];
195
+
196
+ // 3. ASSET diff — asset-lane-aware, so unchanged images aren't re-sent.
197
+ const assetToUpload = [];
198
+ let assetSkipped = 0;
199
+ if (!noAssets && assetAll.length > 0) {
200
+ const remote = await remoteAssetMap(client, projectId);
201
+ for (const f of assetAll) {
202
+ const key = resolveAssetBuildPath(f.path);
203
+ if (assetsAll) {
204
+ assetToUpload.push(f);
205
+ } else if (!remote.has(key)) {
206
+ assetToUpload.push(f); // new
207
+ } else {
208
+ const size = remote.get(key);
209
+ if (size != null && Number(size) !== f.bytes) assetToUpload.push(f); // size changed
210
+ else assetSkipped += 1;
211
+ }
212
+ }
213
+ }
214
+
215
+ const plan = {
216
+ project_id: projectId,
217
+ code_save: codeToSave.length,
218
+ code_delete: codeToDelete.length,
219
+ asset_upload: assetToUpload.length,
220
+ asset_unchanged: assetSkipped,
221
+ asset_skipped_lane: noAssets ? assetAll.length : 0,
222
+ };
223
+ return { codeAll, assetAll, diff, codeToSave, codeToDelete, assetToUpload, assetSkipped, plan };
224
+ }
225
+
226
+ // Thin CLI wrapper: resolve the MCP client + project folder from cwd, then run
227
+ // the (fully testable) orchestration in pushProject.
228
+ export async function runPush({ slug, profile, ...opts } = {}) {
229
+ const baseDir = resolveBaseDir();
230
+ const { client } = await clientFromCwd({ profileOverride: profile });
231
+ const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
232
+ if (!existsSync(folder)) {
233
+ throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
234
+ }
235
+ return pushProject({
236
+ client,
237
+ projectId: info.project_id,
238
+ slug: resolvedSlug,
239
+ folder,
240
+ baseDir,
241
+ info,
242
+ ...opts,
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Core push orchestration — no cwd/network resolution, so it can be driven with
248
+ * a fake MCP client + temp folder in tests. Lane-classifies local files, diffs
249
+ * code (one status call) + assets (assets_list), then batches bulk_save +
250
+ * assets_upload + deletions, with optional commit marker + deploy.
251
+ */
252
+ export async function pushProject({
253
+ client,
254
+ projectId,
255
+ slug: resolvedSlug,
256
+ folder,
257
+ baseDir,
258
+ info = {},
259
+ commit,
260
+ deploy,
261
+ wait = false,
262
+ includeDeletes = false,
263
+ noAssets = false,
264
+ assetsAll = false,
265
+ dryRun = false,
266
+ branch,
267
+ }) {
268
+ const {
269
+ codeAll,
270
+ assetAll,
271
+ codeToSave,
272
+ codeToDelete,
273
+ assetToUpload,
274
+ assetSkipped,
275
+ plan: basePlan,
276
+ } = await computePushPlan({ client, projectId, folder, includeDeletes, noAssets, assetsAll });
277
+ const plan = { project: resolvedSlug, ...basePlan };
278
+
279
+ if (dryRun) {
280
+ console.log('[dry run] push plan:');
281
+ console.log(` code save: ${plan.code_save}`);
282
+ console.log(` code delete: ${plan.code_delete}${includeDeletes ? '' : ' (pass --include-deletes to apply)'}`);
283
+ console.log(` asset upload: ${plan.asset_upload}`);
284
+ console.log(` asset skip: ${plan.asset_unchanged} unchanged${noAssets ? `, ${assetAll.length} lane-skipped (--no-assets)` : ''}`);
285
+ if (codeToSave.length) {
286
+ console.log('\n files to save:');
287
+ for (const f of codeToSave.slice(0, 30)) console.log(` M ${f.path}`);
288
+ if (codeToSave.length > 30) console.log(` ... and ${codeToSave.length - 30} more`);
289
+ }
290
+ if (assetToUpload.length) {
291
+ console.log('\n assets to upload:');
292
+ for (const f of assetToUpload.slice(0, 20)) console.log(` A ${f.path}`);
293
+ if (assetToUpload.length > 20) console.log(` ... and ${assetToUpload.length - 20} more`);
294
+ }
295
+ return { dryRun: true, plan };
296
+ }
297
+
298
+ if (codeToSave.length === 0 && codeToDelete.length === 0 && assetToUpload.length === 0) {
299
+ console.log('Nothing to push — local is in sync with the deploy source.');
300
+ if (deploy) return runDeployFor({ client, projectId, slug: resolvedSlug, environment: deploy, wait });
301
+ return { skipped: true, plan };
302
+ }
303
+
304
+ const failedPaths = [];
305
+ let codeSaved = 0;
306
+
307
+ // ── Code lane: batched, per-batch-verified bulk_save ──────────────────────
308
+ const BATCH_FILES = 40;
309
+ const BATCH_BYTES = 4_000_000; // well under the server's 20MB cap; sized for MCP-transport reliability
310
+ let batch = [];
311
+ let batchBytes = 0;
312
+ let batchNo = 0;
313
+ const flush = async () => {
314
+ if (batch.length === 0) return;
315
+ batchNo += 1;
316
+ const files = batch.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding }));
317
+ const label = batch;
318
+ batch = [];
319
+ batchBytes = 0;
320
+ for (let attempt = 1; attempt <= 2; attempt++) {
321
+ let r;
322
+ try {
323
+ r = (
324
+ await client.callToolJson('project_files_bulk_save', {
325
+ project_id: projectId,
326
+ files,
327
+ commit_message: commit || 'hiveku-sync: push local changes',
328
+ })
329
+ ).data;
330
+ } catch (e) {
331
+ if (attempt >= 2) failedPaths.push(...label.map((f) => f.path));
332
+ continue;
333
+ }
334
+ const succeeded = r?.summary?.succeeded ?? files.length;
335
+ codeSaved += succeeded;
336
+ if (succeeded === files.length) return;
337
+ const bad = (r?.results || []).filter((x) => x.ok === false).map((x) => x.path);
338
+ if (attempt >= 2) failedPaths.push(...(bad.length ? bad : label.map((f) => f.path)));
339
+ }
340
+ };
341
+ for (const f of codeToSave) {
342
+ if (batch.length >= BATCH_FILES || (batchBytes > 0 && batchBytes + f.content.length > BATCH_BYTES)) {
343
+ await flush();
344
+ console.log(` code ${codeSaved}/${codeToSave.length}`);
345
+ }
346
+ batch.push(f);
347
+ batchBytes += f.content.length;
348
+ }
349
+ await flush();
350
+
351
+ // ── Asset lane: assets_upload, bounded concurrency, retry once ────────────
352
+ let assetDone = 0;
353
+ await runLimited(assetToUpload, 5, async (f) => {
354
+ for (let attempt = 1; attempt <= 2; attempt++) {
355
+ try {
356
+ await client.callToolJson('assets_upload', {
357
+ project_id: projectId,
358
+ file_path: f.path,
359
+ content: f.content,
360
+ });
361
+ assetDone += 1;
362
+ if (assetDone % 10 === 0 || assetDone === assetToUpload.length) {
363
+ console.log(` assets ${assetDone}/${assetToUpload.length}`);
364
+ }
365
+ return;
366
+ } catch (e) {
367
+ if (attempt >= 2) failedPaths.push(f.path);
368
+ }
369
+ }
370
+ });
371
+
372
+ // ── Deletions (code lane only) ────────────────────────────────────────────
373
+ let deleted = 0;
374
+ for (const p of codeToDelete) {
375
+ try {
376
+ await client.callToolJson('project_file_delete', { project_id: projectId, file_path: p });
377
+ deleted += 1;
378
+ } catch {
379
+ failedPaths.push(p);
380
+ }
381
+ }
382
+
383
+ console.log(
384
+ `Pushed: ${codeSaved} code file${codeSaved !== 1 ? 's' : ''}` +
385
+ `, ${assetDone} asset${assetDone !== 1 ? 's' : ''}` +
386
+ (deleted ? `, ${deleted} deletion${deleted !== 1 ? 's' : ''}` : '') +
387
+ (assetSkipped ? ` (${assetSkipped} asset${assetSkipped !== 1 ? 's' : ''} unchanged)` : ''),
388
+ );
389
+ if (failedPaths.length) {
390
+ console.log(` ⚠ ${failedPaths.length} file(s) FAILED: ${failedPaths.slice(0, 10).join(', ')}${failedPaths.length > 10 ? '…' : ''}`);
391
+ }
392
+
393
+ // ── Optional: record a Hiveku-native main commit (version-history marker).
394
+ // Files are already saved via bulk_save; this snapshots the current state on
395
+ // `main` so `hiveku-sync` history shows the push. GitHub not required.
396
+ if (commit) {
397
+ try {
398
+ const c = (
399
+ await client.callToolJson('project_vcs_commit', {
400
+ project_id: projectId,
401
+ message: commit,
402
+ branch: branch || info.branch || undefined,
403
+ deletedFiles: codeToDelete.length ? codeToDelete : undefined,
404
+ })
405
+ ).data;
406
+ console.log(` committed on ${c?.branch_name || branch || 'main'} (${c?.checkpoint_hash ? c.checkpoint_hash.slice(0, 12) : c?.id || 'recorded'})`);
407
+ } catch (e) {
408
+ console.log(` ⚠ commit marker failed (files ARE saved): ${(e && e.message) || e}`);
409
+ }
410
+ }
411
+
412
+ // Update manifest timestamps.
413
+ const manifest = await readProjectsManifest(baseDir);
414
+ manifest.projects = manifest.projects || {};
415
+ manifest.projects[resolvedSlug] = {
416
+ ...(manifest.projects[resolvedSlug] || { project_id: projectId }),
417
+ last_push_at: new Date().toISOString(),
418
+ };
419
+ await writeProjectsManifest(baseDir, manifest);
420
+
421
+ const result = { plan, code_saved: codeSaved, assets_uploaded: assetDone, deleted, failed: failedPaths };
422
+ if (deploy) {
423
+ const d = await runDeployFor({ client, projectId, slug: resolvedSlug, environment: deploy, wait });
424
+ result.deploy = d;
425
+ }
426
+ return result;
427
+ }
428
+
429
+ // ── Deploy ──────────────────────────────────────────────────────────────────
430
+
431
+ const DEPLOY_SUCCESS = new Set(['completed', 'ready', 'succeeded', 'success', 'deployed', 'live']);
432
+ const DEPLOY_FAILURE = new Set(['failed', 'error', 'errored', 'cancelled', 'canceled', 'timed_out']);
433
+
434
+ export function normalizeEnv(env) {
435
+ const e = String(env || 'development').toLowerCase();
436
+ if (e === 'preview' || e === 'dev-preview' || e === 'dev') return 'development';
437
+ if (e === 'prod') return 'production';
438
+ if (e === 'stage') return 'staging';
439
+ return e;
440
+ }
441
+
442
+ async function runDeployFor({ client, projectId, slug, environment, wait }) {
443
+ const env = normalizeEnv(environment);
444
+ if (!['development', 'staging', 'production'].includes(env)) {
445
+ throw new Error(`Invalid environment "${environment}" — use development | staging | production.`);
446
+ }
447
+ console.log(`Deploying ${slug} → ${env}…`);
448
+ const started = (
449
+ await client.callToolJson('deploy_site', { project_id: projectId, environment: env })
450
+ ).data;
451
+ const deploymentId = started?.deployment_id || started?.deploy_id || started?.id;
452
+ const startUrl = started?.url || started?.preview_url;
453
+ console.log(` deployment_id: ${deploymentId || '(none returned)'}`);
454
+ if (startUrl) console.log(` url: ${startUrl}`);
455
+
456
+ if (!wait) {
457
+ console.log(' (not waiting — pass --wait to poll to completion)');
458
+ return { deployment_id: deploymentId, environment: env, url: startUrl, waited: false };
459
+ }
460
+ if (!deploymentId) {
461
+ console.log(' ⚠ no deployment_id to poll — check deploy_status manually.');
462
+ return { environment: env, waited: false };
463
+ }
464
+
465
+ const POLL_MS = 8000;
466
+ const MAX_MS = env === 'production' ? 30 * 60 * 1000 : 15 * 60 * 1000;
467
+ const t0 = Date.now();
468
+ let last = '';
469
+ while (Date.now() - t0 < MAX_MS) {
470
+ await new Promise((r) => setTimeout(r, POLL_MS));
471
+ let dep;
472
+ try {
473
+ dep = (await client.callToolJson('deploy_get', { project_id: projectId, deployment_id: deploymentId })).data;
474
+ } catch {
475
+ continue; // transient — keep polling
476
+ }
477
+ const status = String(dep?.status || 'queued').toLowerCase();
478
+ if (status !== last) {
479
+ const secs = Math.round((Date.now() - t0) / 1000);
480
+ console.log(` [${secs}s] ${status}`);
481
+ last = status;
482
+ }
483
+ if (DEPLOY_SUCCESS.has(status)) {
484
+ const url = dep?.url || dep?.preview_url || startUrl;
485
+ console.log(` ✓ deployed: ${url || '(url pending)'}`);
486
+ return { deployment_id: deploymentId, environment: env, status, url, waited: true, ok: true };
487
+ }
488
+ if (DEPLOY_FAILURE.has(status)) {
489
+ console.log(` ✗ deploy ${status}${dep?.error ? `: ${dep.error}` : ''}`);
490
+ if (dep?.build_logs) {
491
+ const tail = String(dep.build_logs).split('\n').slice(-15).join('\n');
492
+ console.log(' --- build log (tail) ---\n' + tail);
493
+ }
494
+ const err = new Error(`Deploy ${status}${dep?.error ? `: ${dep.error}` : ''}`);
495
+ err.deploy = { deployment_id: deploymentId, environment: env, status, error: dep?.error };
496
+ throw err;
497
+ }
498
+ }
499
+ console.log(` ⚠ still ${last || 'pending'} after ${Math.round(MAX_MS / 60000)}min — check deploy_status.`);
500
+ return { deployment_id: deploymentId, environment: env, status: last, waited: true, ok: false };
501
+ }
502
+
503
+ export async function runDeploy({ slugOrId, environment, wait = false, profile }) {
504
+ if (!environment) throw new Error('Usage: hiveku-sync deploy <development|staging|production> [<slug>] [--wait]');
505
+ const baseDir = resolveBaseDir();
506
+ const { client } = await clientFromCwd({ profileOverride: profile });
507
+
508
+ // Resolve project: prefer the pulled-folder manifest (slug), else treat the
509
+ // arg as a slug/UUID via list_projects.
510
+ let projectId;
511
+ let slug = slugOrId;
512
+ try {
513
+ const { slug: s, info } = await findProjectFolder({ slug: slugOrId, baseDir });
514
+ projectId = info.project_id;
515
+ slug = s;
516
+ } catch {
517
+ if (!slugOrId) throw new Error('No project folder detected — pass a project slug or UUID: hiveku-sync deploy <env> <slug>');
518
+ const project = await resolveProject(client, slugOrId);
519
+ projectId = project.id;
520
+ slug = project.slug || project.name || project.id;
521
+ }
522
+ return runDeployFor({ client, projectId, slug, environment, wait });
523
+ }
524
+
525
+ // ── pull-env ──────────────────────────────────────────────────────────────
526
+
527
+ // Resolution mirrors the preview machine (project_secrets_set): _DEV wins for
528
+ // development and is stripped to its base key; _PROD/_PRODUCTION are for
529
+ // production, _STAGING for staging. Suffixes for OTHER tiers are skipped.
530
+ const TIER_SUFFIX = {
531
+ development: ['_DEV'],
532
+ staging: ['_STAGING'],
533
+ production: ['_PROD', '_PRODUCTION'],
534
+ };
535
+ const ALL_SUFFIXES = ['_DEV', '_STAGING', '_PROD', '_PRODUCTION'];
536
+
537
+ export function resolveSecretsForTier(secrets, tier) {
538
+ const want = TIER_SUFFIX[tier] || TIER_SUFFIX.development;
539
+ const base = {};
540
+ const overrides = {};
541
+ for (const [key, value] of Object.entries(secrets || {})) {
542
+ const suffix = ALL_SUFFIXES.find((s) => key.endsWith(s));
543
+ if (!suffix) {
544
+ base[key] = value;
545
+ continue;
546
+ }
547
+ if (want.includes(suffix)) {
548
+ overrides[key.slice(0, -suffix.length)] = value; // strip suffix → base name
549
+ }
550
+ // suffix for another tier → skip
551
+ }
552
+ return { ...base, ...overrides }; // tier override wins over the base key
553
+ }
554
+
555
+ function envFileForTier(tier) {
556
+ if (tier === 'production') return '.env.production.local';
557
+ if (tier === 'staging') return '.env.staging.local';
558
+ return '.env.local';
559
+ }
560
+
561
+ export function serializeEnv(map) {
562
+ const lines = [
563
+ '# Generated by `hiveku-sync pull-env` from Hiveku project secrets.',
564
+ '# Do NOT commit — this file is git-ignored.',
565
+ '',
566
+ ];
567
+ for (const key of Object.keys(map).sort()) {
568
+ let v = String(map[key] ?? '');
569
+ // Quote if it contains whitespace, #, quotes, or newlines.
570
+ if (/[\s#'"\n]/.test(v)) {
571
+ v = `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
572
+ }
573
+ lines.push(`${key}=${v}`);
574
+ }
575
+ return lines.join('\n') + '\n';
576
+ }
577
+
578
+ export async function runPullEnv({ slug, profile, target = 'development' } = {}) {
579
+ const tier = normalizeEnv(target);
580
+ if (!['development', 'staging', 'production'].includes(tier)) {
581
+ throw new Error(`Invalid --target "${target}" — use development | staging | production.`);
582
+ }
583
+ const baseDir = resolveBaseDir();
584
+ const { client } = await clientFromCwd({ profileOverride: profile });
585
+ const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
586
+ const projectId = info.project_id;
587
+
588
+ const res = (await client.callToolJson('project_secrets_list', { project_id: projectId })).data;
589
+ const secrets = res?.secrets || {};
590
+ const count = Object.keys(secrets).length;
591
+ if (count === 0) {
592
+ console.log(`No secrets set on ${resolvedSlug}. Nothing to write.`);
593
+ return { written: 0 };
594
+ }
595
+
596
+ const resolved = resolveSecretsForTier(secrets, tier);
597
+ const outName = envFileForTier(tier);
598
+ const outPath = join(folder, outName);
599
+ await writeFileAtomic(outPath, serializeEnv(resolved));
600
+
601
+ // Make sure the env file is git-ignored so it never gets pushed.
602
+ const gitignorePath = join(folder, '.gitignore');
603
+ let ignore = '';
604
+ try {
605
+ ignore = await readFile(gitignorePath, 'utf8');
606
+ } catch {
607
+ /* no .gitignore yet */
608
+ }
609
+ if (!ignore.split('\n').some((l) => l.trim() === outName)) {
610
+ await writeFileAtomic(
611
+ gitignorePath,
612
+ (ignore && !ignore.endsWith('\n') ? ignore + '\n' : ignore) + outName + '\n',
613
+ );
614
+ }
615
+
616
+ console.log(`Wrote ${Object.keys(resolved).length} var${Object.keys(resolved).length !== 1 ? 's' : ''} → ${outName} (tier: ${tier}, from ${count} secret${count !== 1 ? 's' : ''}).`);
617
+ return { written: Object.keys(resolved).length, path: outPath, tier };
618
+ }