@hiveku-apps/sync 0.1.0 → 0.2.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 CHANGED
@@ -78,6 +78,53 @@ narrowed tool list. Available: `full`, `sales`, `marketing`, `helpdesk`,
78
78
  `pm`, `dev`. Change `HIVEKU_PROFILE` in `.env` and reload the MCP server
79
79
  in your editor to switch.
80
80
 
81
+ ## Coder projects — pull, ship, deploy
82
+
83
+ Beyond memory/rules/skills, `hiveku-sync` can drive a Hiveku **website project**
84
+ end-to-end from the terminal — the same push/deploy loop the VS Code extension's
85
+ Source Control gives you, but headless (Claude Code in a terminal, CI, a cron).
86
+
87
+ ```bash
88
+ hiveku-sync projects # list the account's coder projects
89
+ hiveku-sync pull-project <slug-or-id> # → ./projects/<slug>/ (+ .hiveku/projects.json)
90
+
91
+ cd projects/<slug>
92
+ hiveku-sync pull-env # secrets → .env.local (git-ignored)
93
+ # ...edit files, run the dev server...
94
+ hiveku-sync status # diff local vs the deploy source
95
+ hiveku-sync push # save changes to the deploy source
96
+ hiveku-sync push --deploy development --wait # ...then deploy and wait
97
+ ```
98
+
99
+ **`push` writes the deploy source, lane-routed.** This is the important part.
100
+ `deploy_site` builds from `builder_code_versions` (+ the S3/CDN asset store) —
101
+ **not** a GitHub branch. So `push`:
102
+
103
+ - routes **CDN-servable binary** (images/fonts/video under `public/<subdir>/`)
104
+ to `assets_upload` (S3 → CloudFront). A binary put in `builder_code_versions`
105
+ renders in the Fly preview but **vanishes on deploy** (the build excludes it) —
106
+ `push` never makes that mistake.
107
+ - routes everything else (code, config, text-under-public) to
108
+ `project_files_bulk_save` in small, per-batch-verified batches (transport-safe
109
+ vs one giant call).
110
+ - diffs correctly: code via a single `project_files_status` call (so
111
+ `only_remote` stays reliable — chunking it produces phantom deletions), assets
112
+ via `assets_list` (so unchanged images aren't re-uploaded every push).
113
+
114
+ Flags: `--include-deletes` (remove code paths missing locally), `--no-assets` /
115
+ `--assets-all`, `--commit "msg"` (also record a Hiveku-native `main` commit),
116
+ `--deploy <env> [--wait]`, `--dry-run`.
117
+
118
+ ```bash
119
+ hiveku-sync deploy <development|staging|production> [<slug>] [--wait]
120
+ hiveku-sync pull-env [<slug>] [--target development|staging|production]
121
+ hiveku-sync commit -m "msg" # legacy GitHub-branch commit (GitHub-connected projects only)
122
+ ```
123
+
124
+ `pull-env` applies the same `_DEV` / `_PROD` / `_STAGING` suffix resolution the
125
+ preview machine uses: for `development` it strips `_DEV` to the base key and
126
+ skips `_PROD`/`_STAGING`; `--target production` does the inverse.
127
+
81
128
  ## Safety
82
129
 
83
130
  - **Manifest pin** — `.hiveku/manifest.json` records the API key preview.
@@ -91,7 +138,7 @@ in your editor to switch.
91
138
 
92
139
  ## How writes work
93
140
 
94
- `hiveku-sync` is pull-only. To persist changes back to Olympus, call the
95
- `memory_create` / `memory_update` / `memory_delete` MCP tools through
96
- Claude (or whichever LLM you've wired up). Then `hiveku-sync pull` to
97
- refresh the local copies.
141
+ For **projects**, `hiveku-sync push` / `deploy` write straight back to Hiveku
142
+ (see "Coder projects" above). For **memory/rules/skills**, `pull` is read-only —
143
+ persist changes with the `memory_create` / `memory_update` / `memory_delete` MCP
144
+ tools through Claude, then `hiveku-sync pull` to refresh the local copies.
@@ -16,20 +16,39 @@ import {
16
16
  runProjectStatus,
17
17
  runProjectPush,
18
18
  } from '../src/projects.mjs';
19
+ import { runPush, runDeploy, runPullEnv } from '../src/push.mjs';
20
+ import { pathToFileURL } from 'node:url';
19
21
 
20
- function parseFlags(argv) {
22
+ export function parseFlags(argv) {
21
23
  const positional = [];
22
24
  const flags = {};
23
- // Short flags that take a value (next arg is the value, not a new flag).
25
+ // Short flags that take the NEXT arg as their value.
24
26
  const VALUE_SHORT = new Set(['m']);
27
+ // Long flags that take the next arg as their value when not given with `=`.
28
+ const VALUE_LONG = new Set([
29
+ 'message',
30
+ 'commit',
31
+ 'deploy',
32
+ 'target',
33
+ 'branch',
34
+ 'profile',
35
+ 'parent',
36
+ ]);
25
37
  for (let i = 0; i < argv.length; i++) {
26
38
  const arg = argv[i];
27
39
  if (arg.startsWith('--')) {
28
40
  const eq = arg.indexOf('=');
29
- if (eq === -1) {
30
- flags[arg.slice(2)] = true;
31
- } else {
41
+ if (eq !== -1) {
32
42
  flags[arg.slice(2, eq)] = arg.slice(eq + 1);
43
+ } else {
44
+ const key = arg.slice(2);
45
+ const nextIsValue = i + 1 < argv.length && !argv[i + 1].startsWith('-');
46
+ if (VALUE_LONG.has(key) && nextIsValue) {
47
+ flags[key] = argv[i + 1];
48
+ i += 1;
49
+ } else {
50
+ flags[key] = true;
51
+ }
33
52
  }
34
53
  } else if (arg.startsWith('-') && arg.length > 1) {
35
54
  const key = arg.slice(1);
@@ -59,18 +78,44 @@ Coder projects:
59
78
  projects List the account's coder projects.
60
79
  pull-project <slug-or-id> Pull a project's source into ./projects/<slug>/.
61
80
  [--branch=<name>] Records the project mapping in .hiveku/projects.json.
62
- status [<slug>] [--json] Diff local ./projects/<slug>/ vs Hiveku DB.
63
- cd into the project folder to omit <slug>.
64
- push [<slug>] -m "<message>" Save+commit changed files to the project's
65
- [--branch=<name>] GitHub branch via MCP (same path as the v3
66
- [--include-deletes] sidebar). Add --include-deletes to commit
67
- [--dry-run] paths missing locally as deletions.
81
+ status [<slug>] [--json] Diff local ./projects/<slug>/ vs the deploy source
82
+ (builder_code_versions). cd in to omit <slug>.
83
+
84
+ Ship a project (the agent-native deploy loop):
85
+ push [<slug>] Save local changes to the DEPLOY SOURCE, lane-routed:
86
+ [--include-deletes] images/fonts/video under public/<subdir>/ → the
87
+ [--no-assets] [--assets-all] S3/CDN asset store; everything else → batched,
88
+ [--commit "<msg>"] verified bulk_save. This is what deploy reads.
89
+ [--deploy <env>] [--wait] --commit also records a Hiveku 'main' commit.
90
+ [--dry-run] [--branch=<name>] --deploy <env> [--wait] deploys after the push.
91
+ deploy <env> [<slug>] [--wait] Deploy to development | staging | production and
92
+ (with --wait) poll to a terminal status.
93
+ pull-env [<slug>] Write project secrets → .env.local (git-ignored),
94
+ [--target <env>] with the same _DEV/_PROD/_STAGING resolution the
95
+ preview uses. --target staging|production for the
96
+ other tiers.
97
+ commit [<slug>] -m "<msg>" GitHub-branch commit via project_commit (only for
98
+ [--branch=<name>] GitHub-connected projects; most sites use push).
99
+ [--include-deletes]
68
100
 
69
101
  help Show this message.
70
102
 
71
103
  Profiles: full, sales, marketing, helpdesk, pm, dev
72
104
  `;
73
105
 
106
+ function commitMessageFrom(flags) {
107
+ // --commit "msg" | --commit=msg | -m "msg" | --message "msg". A bare --commit
108
+ // (boolean true) means "commit with no message" → fall back to the default.
109
+ const c = typeof flags['commit'] === 'string' ? flags['commit'] : undefined;
110
+ const m = typeof flags['m'] === 'string' ? flags['m'] : undefined;
111
+ const msg = typeof flags['message'] === 'string' ? flags['message'] : undefined;
112
+ if (c) return c;
113
+ if (m) return m;
114
+ if (msg) return msg;
115
+ if (flags['commit'] === true) return 'hiveku-sync: push';
116
+ return undefined;
117
+ }
118
+
74
119
  async function main() {
75
120
  const [cmd, ...rest] = process.argv.slice(2);
76
121
  const { positional, flags } = parseFlags(rest);
@@ -144,16 +189,55 @@ async function main() {
144
189
  break;
145
190
  }
146
191
  case 'push': {
147
- // Positional layout: `push [<slug>]`. -m / --message is required.
148
- const slug = positional[0];
149
- const message = flags['m'] || flags['message'];
150
- if (!message) {
151
- console.error('Error: push requires -m "<commit message>"\n');
192
+ const deployEnv = typeof flags['deploy'] === 'string' ? flags['deploy'] : undefined;
193
+ await runPush({
194
+ slug: positional[0],
195
+ profile: flags['profile'],
196
+ commit: commitMessageFrom(flags),
197
+ deploy: deployEnv,
198
+ wait: !!flags['wait'],
199
+ includeDeletes: !!flags['include-deletes'],
200
+ noAssets: !!flags['no-assets'],
201
+ assetsAll: !!flags['assets-all'],
202
+ dryRun: !!flags['dry-run'],
203
+ branch: flags['branch'],
204
+ });
205
+ break;
206
+ }
207
+ case 'deploy': {
208
+ // `deploy <env> [<slug>]` — env is the first positional.
209
+ const environment = positional[0];
210
+ if (!environment) {
211
+ console.error('Error: deploy requires an environment: development | staging | production\n');
212
+ console.error(HELP);
213
+ process.exit(1);
214
+ }
215
+ await runDeploy({
216
+ environment,
217
+ slugOrId: positional[1],
218
+ wait: !!flags['wait'],
219
+ profile: flags['profile'],
220
+ });
221
+ break;
222
+ }
223
+ case 'pull-env': {
224
+ await runPullEnv({
225
+ slug: positional[0],
226
+ profile: flags['profile'],
227
+ target: typeof flags['target'] === 'string' ? flags['target'] : 'development',
228
+ });
229
+ break;
230
+ }
231
+ case 'commit': {
232
+ // The legacy GitHub-branch commit path (project_commit). Requires a repo.
233
+ const message = commitMessageFrom(flags);
234
+ if (!message || message === 'hiveku-sync: push') {
235
+ console.error('Error: commit requires -m "<commit message>"\n');
152
236
  console.error(HELP);
153
237
  process.exit(1);
154
238
  }
155
239
  await runProjectPush({
156
- slug,
240
+ slug: positional[0],
157
241
  message,
158
242
  branch: flags['branch'],
159
243
  profile: flags['profile'],
@@ -175,8 +259,14 @@ async function main() {
175
259
  }
176
260
  }
177
261
 
178
- main().catch((err) => {
179
- console.error(`hiveku-sync: ${err.message}`);
180
- if (process.env.HIVEKU_SYNC_DEBUG) console.error(err.stack);
181
- process.exit(1);
182
- });
262
+ // Only run the CLI when invoked directly — importing this module (e.g. from
263
+ // tests to reach parseFlags) must not execute main().
264
+ const isEntrypoint =
265
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
266
+ if (isEntrypoint) {
267
+ main().catch((err) => {
268
+ console.error(`hiveku-sync: ${err.message}`);
269
+ if (process.env.HIVEKU_SYNC_DEBUG) console.error(err.stack);
270
+ process.exit(1);
271
+ });
272
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hiveku-apps/sync",
3
- "version": "0.1.0",
4
- "description": "Sync a Hiveku account's memory/rules/skills + coder projects into a local folder. Pairs with the Hiveku MCP at core.hiveku.com/mcp so Claude Code can drive Hiveku tools per-folder.",
3
+ "version": "0.2.0",
4
+ "description": "Sync a Hiveku account's memory/rules/skills + coder projects into a local folder, and ship projects (push/deploy/pull-env) from the terminal. Pairs with the Hiveku MCP at core.hiveku.com/mcp so Claude Code can drive Hiveku tools per-folder.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Hiveku",
@@ -36,6 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "scripts": {
39
- "start": "node bin/hiveku-sync.mjs"
39
+ "start": "node bin/hiveku-sync.mjs",
40
+ "test": "node --test 'test/*.test.mjs'"
40
41
  }
41
42
  }
package/src/projects.mjs CHANGED
@@ -19,8 +19,11 @@ import {
19
19
  writeFileAtomic,
20
20
  readJson,
21
21
  } from './util.mjs';
22
+ // NOTE: push.mjs imports helpers from this module; the cycle is safe because
23
+ // both sides reference the imports only inside functions (never at module eval).
24
+ import { computePushPlan } from './push.mjs';
22
25
 
23
- const PROJECTS_DIR = 'projects';
26
+ export const PROJECTS_DIR = 'projects';
24
27
  const PROJECTS_MANIFEST = '.hiveku/projects.json';
25
28
 
26
29
  const IGNORE_DIRS = new Set([
@@ -46,11 +49,11 @@ const RUN_HINTS = {
46
49
  static: 'npx serve .',
47
50
  };
48
51
 
49
- function sha256(content) {
52
+ export function sha256(content) {
50
53
  return createHash('sha256').update(content).digest('hex');
51
54
  }
52
55
 
53
- async function clientFromCwd({ profileOverride } = {}) {
56
+ export async function clientFromCwd({ profileOverride } = {}) {
54
57
  const baseDir = process.cwd();
55
58
  const dotenv = await loadDotenv(join(baseDir, '.env'));
56
59
  const apiKey = readEnv(process.env, dotenv, 'OLYMPUS_API_KEY');
@@ -86,7 +89,7 @@ async function listAllRemoteFiles(client, projectId) {
86
89
  return all;
87
90
  }
88
91
 
89
- async function walkLocalFiles(rootDir) {
92
+ export async function walkLocalFiles(rootDir) {
90
93
  const out = [];
91
94
  async function recurse(dir) {
92
95
  let entries;
@@ -125,16 +128,16 @@ function runHintForProjectType(type) {
125
128
  return RUN_HINTS[type] || `# Unknown project_type "${type}". Inspect package.json/requirements.txt to find the dev command.`;
126
129
  }
127
130
 
128
- async function readProjectsManifest(baseDir) {
131
+ export async function readProjectsManifest(baseDir) {
129
132
  const data = await readJson(join(baseDir, PROJECTS_MANIFEST));
130
133
  return data && typeof data === 'object' ? data : { projects: {} };
131
134
  }
132
135
 
133
- async function writeProjectsManifest(baseDir, manifest) {
136
+ export async function writeProjectsManifest(baseDir, manifest) {
134
137
  await writeFileAtomic(join(baseDir, PROJECTS_MANIFEST), JSON.stringify(manifest, null, 2));
135
138
  }
136
139
 
137
- async function resolveProject(client, slugOrId) {
140
+ export async function resolveProject(client, slugOrId) {
138
141
  // Accept either UUID (use directly) or slug (look up via list_projects).
139
142
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
140
143
  if (UUID_RE.test(slugOrId)) {
@@ -227,19 +230,22 @@ export async function runPullProject({ slugOrId, profile, branch }) {
227
230
  runHintForProjectType(project.project_type),
228
231
  '```',
229
232
  '',
230
- '## Commit changes back',
233
+ '## Ship changes back',
231
234
  '',
232
- 'This folder is NOT a git clone — it\'s a snapshot. Use the Hiveku MCP `project_commit` tool',
233
- 'to write edits back to the project\'s GitHub branch (same path the v3 sidebar uses).',
235
+ "This folder is NOT a git clone — it's a snapshot. `push` writes your edits to the",
236
+ 'DEPLOY SOURCE (what `deploy` builds from), lane-routing images/fonts/video to the',
237
+ 'S3/CDN asset store and everything else to versioned code files.',
234
238
  '',
235
239
  'From this folder:',
236
240
  '',
237
241
  '```bash',
238
- `hiveku-sync status # diff local vs remote`,
239
- `hiveku-sync push -m "your message" # save + commit changed files via MCP`,
242
+ 'hiveku-sync pull-env # project secrets → .env.local',
243
+ 'hiveku-sync status # diff local vs the deploy source',
244
+ 'hiveku-sync push # save changes back',
245
+ 'hiveku-sync push --deploy development --wait # ...then deploy and wait',
240
246
  '```',
241
247
  '',
242
- 'Or ask Claude in this workspace to commit the changed files for you.',
248
+ 'Or ask Claude in this workspace to push/deploy for you.',
243
249
  '',
244
250
  ]
245
251
  .filter((line) => line !== undefined)
@@ -263,7 +269,7 @@ export async function runPullProject({ slugOrId, profile, branch }) {
263
269
  return { dest, written, skipped, projectId, slug: localSlug };
264
270
  }
265
271
 
266
- async function findProjectFolder({ slug, baseDir }) {
272
+ export async function findProjectFolder({ slug, baseDir }) {
267
273
  // Allow status/push to be called from inside ./projects/<slug>/, ./<slug>/,
268
274
  // or the account root with an explicit slug arg.
269
275
  const manifest = await readProjectsManifest(baseDir);
@@ -310,39 +316,68 @@ export async function runProjectStatus({ slug, profile, json = false }) {
310
316
  throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
311
317
  }
312
318
 
313
- const localManifest = await buildLocalManifest(folder);
314
- const diff = (await client.callToolJson('project_files_status', {
315
- project_id: info.project_id,
316
- local: localManifest,
317
- })).data;
319
+ // Deploy-source-accurate + asset-lane-aware — the SAME diff `push` runs, so
320
+ // status never flags unchanged images as only_local (code goes through
321
+ // project_files_status; assets through assets_list).
322
+ const { diff, assetAll, assetToUpload, assetSkipped, plan } = await computePushPlan({
323
+ client,
324
+ projectId: info.project_id,
325
+ folder,
326
+ includeDeletes: true,
327
+ });
318
328
 
319
329
  if (json) {
320
- console.log(JSON.stringify({ slug: resolvedSlug, project_id: info.project_id, ...diff }, null, 2));
321
- return diff;
330
+ console.log(
331
+ JSON.stringify(
332
+ {
333
+ slug: resolvedSlug,
334
+ project_id: info.project_id,
335
+ code: diff,
336
+ assets: {
337
+ to_upload: assetToUpload.map((f) => f.path),
338
+ unchanged: assetSkipped,
339
+ total_local: assetAll.length,
340
+ },
341
+ plan,
342
+ },
343
+ null,
344
+ 2,
345
+ ),
346
+ );
347
+ return { diff, plan };
322
348
  }
323
349
 
324
- const sum = summarizeDiff(diff);
350
+ const changed = diff.changed?.length || 0;
351
+ const onlyLocal = diff.only_local?.length || 0;
352
+ const onlyRemote = diff.only_remote?.length || 0;
353
+ const unchanged = diff.same_count || 0;
325
354
  console.log(`Project: ${resolvedSlug} (${info.project_id})`);
326
- console.log(` changed: ${sum.changed}`);
327
- console.log(` only_local: ${sum.only_local}`);
328
- console.log(` only_remote: ${sum.only_remote}`);
329
- console.log(` unchanged: ${sum.same}`);
330
- if (sum.changed > 0) {
331
- console.log('\nChanged files:');
332
- for (const f of diff.changed.slice(0, 20)) console.log(` M ${f.path}`);
333
- if (diff.changed.length > 20) console.log(` ... and ${diff.changed.length - 20} more`);
334
- }
335
- if (sum.only_local > 0) {
336
- console.log('\nLocal-only (will be added on push):');
337
- for (const f of diff.only_local.slice(0, 20)) console.log(` + ${f.path}`);
338
- if (diff.only_local.length > 20) console.log(` ... and ${diff.only_local.length - 20} more`);
339
- }
340
- if (sum.only_remote > 0) {
341
- console.log('\nRemote-only (deleted locally or never pulled — pass --include-deletes to push to commit deletions):');
342
- for (const f of diff.only_remote.slice(0, 20)) console.log(` - ${f.path}`);
343
- if (diff.only_remote.length > 20) console.log(` ... and ${diff.only_remote.length - 20} more`);
344
- }
345
- return diff;
355
+ console.log('Compared against the DEPLOY SOURCE (builder_code_versions + asset store).\n');
356
+ console.log('Code:');
357
+ console.log(` changed: ${changed}`);
358
+ console.log(` added: ${onlyLocal}`);
359
+ console.log(` remote-only: ${onlyRemote}`);
360
+ console.log(` unchanged: ${unchanged}`);
361
+ console.log('Assets (images/fonts/video under public/<subdir>/):');
362
+ console.log(` to upload: ${assetToUpload.length} (new or changed)`);
363
+ console.log(` unchanged: ${assetSkipped}`);
364
+ console.log(` total local: ${assetAll.length}`);
365
+
366
+ const printList = (items, marker, header, getPath = (f) => f.path) => {
367
+ if (!items || items.length === 0) return;
368
+ console.log(`\n${header}`);
369
+ for (const f of items.slice(0, 20)) console.log(` ${marker} ${getPath(f)}`);
370
+ if (items.length > 20) console.log(` ... and ${items.length - 20} more`);
371
+ };
372
+ printList(diff.changed, 'M', 'Changed code:');
373
+ printList(diff.only_local, '+', 'Added code (will be created on push):');
374
+ printList(
375
+ diff.only_remote,
376
+ '-',
377
+ 'Remote-only code (deleted locally or never pulled — push --include-deletes to delete):',
378
+ );
379
+ printList(assetToUpload, 'A', 'Assets to upload (new or changed):');
380
+ return { diff, plan };
346
381
  }
347
382
 
348
383
  export async function runProjectPush({
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
+ }