@hiveku-apps/sync 0.5.4 → 0.5.6

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
@@ -11,8 +11,8 @@ Local sync CLI for a Hiveku account. Pairs with the Hiveku MCP server at
11
11
  (id, name, type, domain, owner, version, updated_at).
12
12
  It also writes the account memory (see "Account memory" below) to
13
13
  `hiveku-data/account/ACCOUNT_MEMORY.md`, read-only, and brings the folder
14
- layout and memory rules in `CLAUDE.md` up to date (see "CLAUDE.md stays
15
- current" below).
14
+ layout, the memory rules and the plan rules in `CLAUDE.md` up to date (see
15
+ "CLAUDE.md stays current" below).
16
16
 
17
17
  The goal: open one folder per Hiveku tenant in VS Code with Claude Code,
18
18
  with the account's memory mirrored as local `.md` files Claude can read
@@ -130,8 +130,8 @@ Pass `--trust` to pre-trust the folder in `~/.codex/config.toml` (so Codex loads
130
130
  the repo config without its first-run trust prompt).
131
131
 
132
132
  **Approvals.** Codex asks first only for a **fixed list** of high-risk Hiveku
133
- tools: the 246 names in `src/codex-prompt-tools.json`, the same list as the
134
- Hiveku Codex plugin 0.1.16. It includes `deploy_site`, `site_delete`,
133
+ tools: the 278 names in `src/codex-prompt-tools.json`, the same list as the
134
+ Hiveku Codex plugin 0.3.13. It includes `deploy_site`, `site_delete`,
135
135
  `project_files_bulk_save` and every `hiveku_batch`, and it is written as
136
136
  `[mcp_servers.hiveku.tools.<tool>] approval_mode = "prompt"`. **Every other tool
137
137
  on the `hiveku` server runs without asking** (`default_tools_approval_mode =
@@ -160,6 +160,15 @@ The folder has to carry the list itself: Codex uses the folder's
160
160
  `[mcp_servers.hiveku]` instead of the plugin's, including for approvals, so the
161
161
  plugin's prompts do not apply here.
162
162
 
163
+ **Revealing values.** Because `project_secrets_reveal` and `hiveku_batch` ask
164
+ first here, the block also sends the header `X-Hiveku-Client =
165
+ "hiveku-sync/<version>"`: the declaration that this folder asks a person before
166
+ a reveal. Hiveku then lets a reveal through after your Codex prompt instead of
167
+ also asking for a dashboard approval (a reveal inside `hiveku_batch` still gets
168
+ the dashboard approval). When your own table for either of those two tools wins
169
+ over the block, the block sends the plain `codex` label instead, and every
170
+ reveal takes the dashboard approval.
171
+
163
172
  Headless and bypass modes:
164
173
 
165
174
  - **`codex exec` never stops to ask.** Under a `read-only` or `workspace-write`
@@ -253,7 +262,7 @@ Your site, unless the tier is bound to the branch.
253
262
 
254
263
  ```bash
255
264
  hiveku-sync deploy <development|staging|production> [<slug>] [--wait]
256
- hiveku-sync pull-env [<slug>] [--target development|staging|production]
265
+ hiveku-sync pull-env [<slug>] [--target development|staging|production] [--yes] [--no-wait] [--approval-token <token>]
257
266
  ```
258
267
 
259
268
  ## Versions
@@ -295,9 +304,16 @@ their own version first, and never publishes: run `hiveku-sync deploy` after
295
304
  it when the live site should match. Images, fonts and videos in the shared
296
305
  asset store, the database, CMS entries and secrets are not part of versions.
297
306
 
298
- `pull-env` applies the same `_DEV` / `_PROD` / `_STAGING` suffix resolution the
299
- preview machine uses: for `development` it strips `_DEV` to the base key and
300
- skips `_PROD`/`_STAGING`; `--target production` does the inverse.
307
+ `pull-env` writes the values one tier runs with, through
308
+ `project_secrets_reveal`, as Hiveku resolves them (`FOO_PROD` over `FOO` for
309
+ production, `FOO_DEV` for development). A person approves every pull: it prints
310
+ the approval link, then waits up to 10 minutes, asking again every 5 seconds
311
+ (`--no-wait` stops after the link, and `--approval-token <token>` resumes the
312
+ wait). Development goes to `.env.local`; `--target staging` to
313
+ `.env.staging.local`; `--target production` to `.env.production.local`, and only
314
+ with `--yes`. The file is mode 0600, git-ignored, and written only when a value
315
+ came back. Sensitive (write-only) variables and platform credentials never come
316
+ back; their names are listed in the file as comments with the reason.
301
317
 
302
318
  ## Safety
303
319
 
@@ -331,7 +347,8 @@ region `hiveku-sync codex` rewrites.
331
347
  ## CLAUDE.md stays current
332
348
 
333
349
  `init` writes `CLAUDE.md` once, and the rest of the file is yours from then on,
334
- except its "Folder layout" and "Memory writes" sections. They sit between
350
+ except its "Folder layout", "Memory writes" and "Your plan and its limits"
351
+ sections. They sit between
335
352
  `<!-- hiveku:memory:begin -->` and `<!-- hiveku:memory:end -->`, and `pull` (and
336
353
  `codex`) bring them up to date, so a folder an older release made stops teaching
337
354
  the old layout and the old `memory_update` call:
@@ -347,6 +364,12 @@ The kept-current part also states that Hiveku Memory is the source of truth,
347
364
  in the Hiveku MCP server's own words, and that the local files are a mirror
348
365
  memory wins over. `AGENTS.md` says the same.
349
366
 
367
+ Since 0.5.6 it also carries the account's plan and its limits, in the words
368
+ Hiveku writes once for every agent (copied byte for byte and pinned by
369
+ sha256): call `account_usage_get` before work the plan counts, and a tool
370
+ answer with `limit_kind` is the plan's answer, not a fault, so tell the person
371
+ and never retry or report it. `AGENTS.md` carries the one-line version.
372
+
350
373
  A `CLAUDE.md` that is read-only, a link to another file, or whose markers are not
351
374
  there once each and in order is left alone (`pull` says so). `pull` never creates
352
375
  a missing one.
@@ -40,6 +40,7 @@ export function parseFlags(argv) {
40
40
  'confirm',
41
41
  'fingerprint',
42
42
  'limit',
43
+ 'approval-token',
43
44
  ]);
44
45
  for (let i = 0; i < argv.length; i++) {
45
46
  const arg = argv[i];
@@ -130,10 +131,14 @@ Ship a project (the agent-native deploy loop):
130
131
  and version ARE saved, the deploy failed.
131
132
  deploy <env> [<slug>] [--wait] Deploy to development | staging | production and
132
133
  (with --wait) poll to a terminal status.
133
- pull-env [<slug>] Write project secrets → .env.local (git-ignored),
134
- [--target <env>] with the same _DEV/_PROD/_STAGING resolution the
135
- preview uses. --target staging|production for the
136
- other tiers.
134
+ pull-env [<slug>] Write one tier's values → .env.local (git-ignored,
135
+ [--target <env>] [--yes] mode 0600, values never printed) through the
136
+ [--approval-token <t>] reveal: a person approves on the link it prints,
137
+ [--no-wait] and it waits up to 10 minutes. --target staging
138
+ writes .env.staging.local; --target production
139
+ writes .env.production.local and needs --yes.
140
+ --no-wait stops after the link (exit 3);
141
+ --approval-token resumes that wait.
137
142
 
138
143
  Versions (Your site = the project's main source; rollback never publishes):
139
144
  commit [<slug>] [-m "<name>"] Save what is on Hiveku and not yet in a version as
@@ -401,11 +406,16 @@ async function main() {
401
406
  break;
402
407
  }
403
408
  case 'pull-env': {
404
- await runPullEnv({
405
- slug: positional[0],
406
- profile: flags['profile'],
407
- target: typeof flags['target'] === 'string' ? flags['target'] : 'development',
408
- });
409
+ setExitCode(
410
+ await runPullEnv({
411
+ slug: positional[0],
412
+ profile: flags['profile'],
413
+ target: typeof flags['target'] === 'string' ? flags['target'] : 'development',
414
+ yes: flags['yes'] === true,
415
+ approvalToken: typeof flags['approval-token'] === 'string' ? flags['approval-token'] : undefined,
416
+ wait: flags['no-wait'] !== true,
417
+ }),
418
+ );
409
419
  break;
410
420
  }
411
421
  case 'commit': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiveku-apps/sync",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
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",
package/src/claude-md.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  * The part of an account folder's CLAUDE.md that hiveku-sync keeps current:
3
3
  * the folder layout and the memory-write rules, between two markers. Since
4
4
  * 2026-10-03 it also states that Hiveku Memory is the source of truth (the MCP
5
- * server's words) and that the local files are a mirror memory wins over.
5
+ * server's words) and that the local files are a mirror memory wins over, and
6
+ * since 0.5.6 the account's plan and its limits (the builder's words, pinned).
6
7
  *
7
8
  * `init` writes CLAUDE.md once. Everything outside the markers is the user's
8
9
  * from then on, but the layout and the memory rules change with releases
@@ -55,6 +56,42 @@ export const WORK_LOG_LINE =
55
56
  "Hiveku records this session's Doing at its first change and its Done when the session goes quiet " +
56
57
  'or ends. When the work ends, send a Done with `memory_log_add` and a one-line summary of what you ' +
57
58
  'did, if you want the log to say more than the count of changes; leave `thread` out.';
59
+
60
+ /**
61
+ * What every agent is told about the account's plan and its limits, written once
62
+ * in the builder (hiveku_builder src/lib/platform-guide/plan-usage.ts, builder
63
+ * #1332, served with no key at /api/public/platform-guide/plan-usage) and copied
64
+ * here byte for byte, as the MCP server, the agent servers, the Claude Code and
65
+ * Codex plugins and the VS Code extension copy it. test/claude-md.test.mjs pins
66
+ * both texts to the builder's sha256, so a change there is a change here, made on
67
+ * purpose. Never reword them.
68
+ *
69
+ * PLAN_USAGE_RULES, the full block, is the kept-current part's "Your plan and its
70
+ * limits" section, so `pull` brings it to folders an earlier release made.
71
+ */
72
+ export const PLAN_USAGE_RULES = `Hiveku rules: the account's plan and its limits
73
+ - Every paid plan (Starter, Standard, Premium) opens every feature except the Outbound add-on. Plans differ in how much of each thing they include.
74
+ - Before work that adds something the plan counts (a contact import, a marketing send, phone numbers, social profiles, ad campaigns, websites, automations, databases, AI images or video), call account_usage_get and tell the person what the plan includes, what is used and what happens past the number.
75
+ - A tool answer with limit_kind is not a fault: nothing was done. Never retry it unchanged, never look for a way around it, and never report it as a Hiveku problem. Tell the person in one or two calm sentences what waits and why (the answer's message), and do what its hint says.
76
+ - When limit_kind is plan_limit, not_on_plan or ai_credits, also say when it starts again (resets_at, if any), and the next plan with its number and price (next_plan, next_limit, plans). An owner or admin can upgrade in Settings > Billing (billing_url). When try_next_plan.available is true, they can instead try the next plan free for 14 days, once (try_next_plan.how says where). For billing, add_on, setup and spend_cap, say only what the message and the hint say.
77
+ - Where past is billed, going past the number costs money (rate, up to ceiling): say what it costs before you go past it.
78
+ - Take every number, price and date from account_usage_get or the answer itself, never from memory. Never change the plan, start a try or buy credits or add-ons: an owner or admin does that (billing_url, try_next_plan.how and features.outbound.how say where).
79
+ - Say this only to the account's own people, never to a customer or caller.`;
80
+
81
+ /**
82
+ * The builder's PLAN_LIMIT_LINE (same source and pins as PLAN_USAGE_RULES): one
83
+ * line for AGENTS.md's "Hiveku itself in your way?" item, right after the
84
+ * memory_write_refused sentence (codex.mjs), so a tool refused at the plan's
85
+ * number is told to the person and never retried or reported.
86
+ */
87
+ export const PLAN_LIMIT_LINE =
88
+ "A tool answer with limit_kind (a plan's number reached, something the plan does not include, AI " +
89
+ "credits used up, billing that needs attention, an add-on or phones not set up, a chat's spending " +
90
+ 'limit reached) is not a fault: nothing was done. Never retry it unchanged or report it; tell the ' +
91
+ 'person its message and do what its hint says. Only for plan_limit, not_on_plan and ai_credits, also ' +
92
+ 'say when it starts again and the next plan and its number, using account_usage_get or the answer for ' +
93
+ 'every number, and point an owner or admin to its billing_url.';
94
+
58
95
  export const CLAUDE_MD_END = '<!-- hiveku:memory:end -->';
59
96
 
60
97
  /** The kept-current part: the markers and the two sections between them. */
@@ -62,8 +99,9 @@ export function claudeMdMemoryRegion() {
62
99
  return `${CLAUDE_MD_BEGIN}
63
100
  ## Folder layout
64
101
 
65
- \`hiveku-sync pull\` keeps this section and "Memory writes" below current. Where
66
- anything else in this file disagrees with them, follow these two sections.
102
+ \`hiveku-sync pull\` keeps this section, "Memory writes" and "Your plan and its
103
+ limits" below current. Where anything else in this file disagrees with them,
104
+ follow these three sections.
67
105
 
68
106
  ${SOURCE_OF_TRUTH_LINE}
69
107
  ${WORK_LOG_LINE}
@@ -143,6 +181,10 @@ keeps or removes it), and tell the user that owners and admins edit it on the
143
181
  Hiveku dashboard under Account memory.
144
182
 
145
183
  Then run \`hiveku-sync pull\` to refresh the local copies.
184
+
185
+ ## Your plan and its limits
186
+
187
+ ${PLAN_USAGE_RULES}
146
188
  ${CLAUDE_MD_END}`;
147
189
  }
148
190
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "source": "locus2012/hiveku-codex-plugin main (5ed2e1a, 0.3.11), plugins/hiveku/.mcp.json: every hiveku.tools entry with approval_mode \"prompt\" (project_vcs_rollback included)",
2
+ "source": "locus2012/hiveku-codex-plugin feature/secrets-b3-declare-client (4f9d569, 0.3.14), plugins/hiveku/.mcp.json: every hiveku.tools entry with approval_mode \"prompt\" (project_vcs_rollback included)",
3
3
  "why": "hiveku-sync writes its own [mcp_servers.hiveku] into a Codex folder. Codex then uses that entry, not the plugin's, for the server's connection and for every tool's approval mode, so these prompts must be written here too. Refresh this list whenever the Codex plugin's prompt list changes.",
4
4
  "tools": [
5
5
  "deploy_site",
@@ -23,6 +23,12 @@
23
23
  "project_secrets_delete",
24
24
  "project_secrets_apply_to_preview",
25
25
  "project_secrets_reveal",
26
+ "project_secrets_replace",
27
+ "project_secrets_mark_sensitive",
28
+ "project_ab_test_create",
29
+ "project_ab_test_update",
30
+ "project_ab_test_action",
31
+ "project_ab_test_delete",
26
32
  "hiveku_batch",
27
33
  "project_checkpoint_restore",
28
34
  "checkpoint_restore",
package/src/codex.mjs CHANGED
@@ -45,7 +45,7 @@ import { formatKey, readTomlDeclarations } from './toml.mjs';
45
45
  import { loadDotenv, safeSlug } from './util.mjs';
46
46
  import { ownerFolder } from './knowledge-files.mjs';
47
47
  import { SHARED_OWNER, ownerLabel, ownerOrder } from './memory-owner.mjs';
48
- import { SOURCE_OF_TRUTH_LINE, WORK_LOG_LINE } from './claude-md.mjs';
48
+ import { PLAN_LIMIT_LINE, SOURCE_OF_TRUTH_LINE, WORK_LOG_LINE } from './claude-md.mjs';
49
49
 
50
50
  /**
51
51
  * The FIXED list of high-risk Hiveku tools that Codex asks about before
@@ -95,6 +95,35 @@ export const CODEX_PROMPT_TOOLS = [
95
95
  ...new Set(JSON.parse(readFileSync(new URL('./codex-prompt-tools.json', import.meta.url), 'utf8')).tools),
96
96
  ];
97
97
 
98
+ /** This package's version, as published (package.json ships in every npm package). */
99
+ export const SYNC_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
100
+
101
+ /**
102
+ * X-Hiveku-Client for the folder's [mcp_servers.hiveku]: `hiveku-sync/<version>`,
103
+ * the declaration that this config asks a person before project_secrets_reveal
104
+ * (plan "Server-side approval"; the builder's src/lib/secrets/reveal-client.ts).
105
+ * The MCP server forwards it to the builder, which lets a release at or above
106
+ * its listed minimum skip the dashboard approval for a reveal Codex prompted
107
+ * for. Set here, never by the model.
108
+ *
109
+ * True only while the block itself writes the prompt for the reveal and for
110
+ * hiveku_batch (which can carry a reveal): when the folder's own config
111
+ * declares either table, the user's setting wins (userTables) and may not ask,
112
+ * so the block sends the plain `codex` label instead. The MCP server reads
113
+ * both as Codex for its memory-log label and rate-limit bucket. The CLI's own
114
+ * calls (pull-env included) send no X-Hiveku-Client at all, so a reveal they
115
+ * make takes the dashboard approval.
116
+ */
117
+ export const DECLARED_CLIENT = `hiveku-sync/${SYNC_VERSION}`;
118
+ const DECLARATION_NEEDS_PROMPTS = ['project_secrets_reveal', 'hiveku_batch'];
119
+
120
+ /** The X-Hiveku-Client value for a block that leaves out the user's own tables. */
121
+ export function codexClientHeader(userTables = []) {
122
+ const skip = new Set(userTables);
123
+ const asks = DECLARATION_NEEDS_PROMPTS.every((tool) => CODEX_PROMPT_TOOLS.includes(tool) && !skip.has(tool));
124
+ return asks ? DECLARED_CLIENT : 'codex';
125
+ }
126
+
98
127
  /** A TOML bare key when the name allows it, a quoted key otherwise. */
99
128
  function tomlKey(name) {
100
129
  return /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
@@ -183,6 +212,7 @@ ${digest}
183
212
  - **Hiveku itself in your way?** A Hiveku tool still failing after one sensible retry → \`hiveku_report_issue\`;
184
213
  a capability no tool offers (search \`hiveku_docs_search\` first) → \`hiveku_request_feature\`. No secrets or personal data.
185
214
  A memory write refused with \`memory_write_refused\` is not a failure: show the person its message and link, follow its hint, and never retry it unchanged or report it.
215
+ ${PLAN_LIMIT_LINE}
186
216
  - **Tell the user only if it changes what they get:** one or two calm sentences — flagged to the Hiveku team (give the
187
217
  ref), the team is quick to fix these, you'll let them know when it's sorted. No error codes, blame or promised times.
188
218
  - Video generation is paid + capped — \`marketing_generate_video\` with \`dry_run: true\` first.
@@ -309,14 +339,16 @@ function codexTomlRegion({ baseUrl, profile, apiKey, userTables = [] }) {
309
339
  // MCP server (Codex config reference, "mcp_servers.<id>.http_headers"; the
310
340
  // docs' example is `http_headers = { "X-Figma-Region" = "us-east-1" }`, next
311
341
  // to bearer_token_env_var). TOML allows the key once per table, so the
312
- // client label rides in the same inline table as an inlined key.
313
- // X-Hiveku-Client: codex is what the MCP server's memory log shows as
314
- // "via Codex"; it is a label, never authentication. The key and the URL come
315
- // from .env, so they are escaped: a quote in either would otherwise end the
316
- // string early and leave the block itself unreadable.
342
+ // client header rides in the same inline table as an inlined key.
343
+ // X-Hiveku-Client is hiveku-sync/<version> (codexClientHeader): the
344
+ // declaration that this block asks before a reveal, which the MCP server's
345
+ // memory log still shows as "via Codex"; it is never authentication. The key
346
+ // and the URL come from .env, so they are escaped: a quote in either would
347
+ // otherwise end the string early and leave the block itself unreadable.
348
+ const client = tomlString(codexClientHeader(userTables));
317
349
  const auth = apiKey
318
- ? `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)}, "X-Hiveku-Client" = "codex" }`
319
- : `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" = "codex" }`;
350
+ ? `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)}, "X-Hiveku-Client" = ${client} }`
351
+ : `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" = ${client} }`;
320
352
  // default_tools_approval_mode = "approve": this folder exists so the agent
321
353
  // can operate the account — per-call approval prompts would also cancel
322
354
  // every tool call in headless `codex exec` (CI/cron, this lane's core use)
@@ -0,0 +1,311 @@
1
+ /**
2
+ * `hiveku-sync pull-env`: one tier of a project's variables into a local env
3
+ * file, through project_secrets_reveal (plan B3), never project_secrets_list.
4
+ *
5
+ * The same flow and file rules as the Claude Code plugin's `hiveku env pull`
6
+ * (hiveku-claude-plugin lib/env-pull.mjs): keep the two in step.
7
+ *
8
+ * WHY THE REVEAL. Agents read secret values only for non-sensitive keys,
9
+ * through a reveal that asks every time and is logged (Abe, 2026-10-06).
10
+ * project_secrets_list stops returning values at the cutover (it answers
11
+ * values_moved without metadata_only); the reveal returns the values the tier
12
+ * runs with, resolved by the server (FOO_PROD over FOO, the deploy's own
13
+ * precedence), never a sensitive key and never a platform credential.
14
+ *
15
+ * WHO APPROVES. This CLI's own MCP client sends no X-Hiveku-Client, so it does
16
+ * not declare that it asked anyone (the Codex config this package writes does,
17
+ * codex.mjs DECLARED_CLIENT, because Codex prompts there). Every pull takes
18
+ * the dashboard approval: the CLI prints the approval link, then repeats the
19
+ * same call with the token every 5 seconds for up to 10 minutes. A poll while
20
+ * nobody has decided does not spend the project's reveals.
21
+ *
22
+ * THE FILE: written only when a value came back (an empty answer never
23
+ * replaces an existing file); mode 0600 even over an existing file; values
24
+ * encoded so dotenv + dotenv-expand (Next.js, Vite, Expo) read them back
25
+ * exactly; withheld names as one-line comments with their reasons. Only names
26
+ * reach the terminal.
27
+ */
28
+ import { promises as fs } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { randomUUID } from 'node:crypto';
31
+
32
+ export const REVEAL_TOOL = 'project_secrets_reveal';
33
+
34
+ /** How often the CLI asks again while a person decides, and for how long. */
35
+ export const APPROVAL_POLL_MS = 5_000;
36
+ export const APPROVAL_WAIT_MS = 10 * 60_000;
37
+ /** Polls that fail in a row (network, 5xx) before the wait gives up. */
38
+ const MAX_POLL_FAILURES = 3;
39
+
40
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
41
+ const TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+
43
+ export class EnvPullError extends Error {
44
+ constructor(code, message) {
45
+ super(message);
46
+ this.name = 'EnvPullError';
47
+ this.code = code;
48
+ }
49
+ }
50
+
51
+ /** Which file a tier's values go to (Next.js loads .env.production.local only for production builds). */
52
+ export function envFileForTier(tier) {
53
+ if (tier === 'production') return '.env.production.local';
54
+ if (tier === 'staging') return '.env.staging.local';
55
+ return '.env.local';
56
+ }
57
+
58
+ /** An approval token from the command line: refused unless it has the token's shape. */
59
+ export function approvalTokenFrom(raw) {
60
+ if (raw === undefined) return undefined;
61
+ const token = String(raw).trim();
62
+ if (!TOKEN_RE.test(token)) throw new EnvPullError('bad_token', 'That is not an approval token. Use the token the first run printed.');
63
+ return token;
64
+ }
65
+
66
+ /** One line of server text for a comment or the terminal: no line breaks or control characters. */
67
+ function oneLine(text, max = 300) {
68
+ return String(text ?? '')
69
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ')
70
+ .replace(/\s+/g, ' ')
71
+ .trim()
72
+ .slice(0, max);
73
+ }
74
+
75
+ function asObject(value) {
76
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
77
+ }
78
+
79
+ /**
80
+ * What one tools/call of project_secrets_reveal answered:
81
+ * { kind: 'values', values, withheld, notApproved, tier }
82
+ * { kind: 'approval_required', approval, withheld }
83
+ * { kind: 'pending' }
84
+ * { kind: 'refused', code, message }
85
+ * The MCP proxy returns a builder error as a tool result
86
+ * { error, status, details: { code, message } }, with isError set.
87
+ */
88
+ export function readRevealAnswer(res) {
89
+ if (res?.error) {
90
+ return { kind: 'refused', code: 'rpc_error', message: oneLine(res.error.message || 'The Hiveku server refused the call.') };
91
+ }
92
+ const text = res?.result?.content?.[0]?.text;
93
+ let body = null;
94
+ try {
95
+ body = typeof text === 'string' ? JSON.parse(text) : null;
96
+ } catch {
97
+ body = null;
98
+ }
99
+ body = asObject(body);
100
+ if (!body) return { kind: 'refused', code: 'unreadable_answer', message: 'The reveal answered with something that is not JSON.' };
101
+
102
+ const failed = res?.result?.isError === true || (typeof body.error === 'string' && typeof body.status === 'number' && body.status >= 400);
103
+ if (failed) {
104
+ const details = asObject(body.details) ?? body;
105
+ const code = String(details.code || body.code || details.error || body.error || 'refused');
106
+ if (code === 'approval_pending') return { kind: 'pending' };
107
+ return { kind: 'refused', code, message: oneLine(details.message || body.message || body.error || 'The reveal was refused.') };
108
+ }
109
+
110
+ const data = asObject(body.data) && !('values' in body) && !('code' in body) ? body.data : body;
111
+ const withheld = Array.isArray(data.withheld) ? data.withheld.filter((w) => asObject(w) && typeof w.key === 'string') : [];
112
+ if (data.code === 'approval_required') {
113
+ const approval = asObject(data.approval);
114
+ // The link and the token are printed for the person, so both must be plain: an https link with
115
+ // no spaces or control characters, and a token of the token's own shape.
116
+ if (!approval || !TOKEN_RE.test(String(approval.token)) || !/^https:\/\/[^\s\u0000-\u001f\u007f]+$/.test(String(approval.approve_url))) {
117
+ return { kind: 'refused', code: 'unexpected_answer', message: 'The reveal asked for an approval without a usable link.' };
118
+ }
119
+ return { kind: 'approval_required', approval, withheld };
120
+ }
121
+ const values = asObject(data.values);
122
+ if (values) {
123
+ const clean = {};
124
+ for (const [key, value] of Object.entries(values)) if (typeof value === 'string') clean[key] = value;
125
+ const notApproved = Array.isArray(data.not_approved) ? data.not_approved.filter((k) => typeof k === 'string') : [];
126
+ return { kind: 'values', values: clean, withheld, notApproved, tier: data.tier };
127
+ }
128
+ return { kind: 'refused', code: 'unexpected_answer', message: 'The reveal answered in a shape this version does not read. Update hiveku-sync.' };
129
+ }
130
+
131
+ /** A refusal as one sentence for the terminal. */
132
+ function refusalText(answer) {
133
+ const byCode = {
134
+ approval_declined: 'A person declined the request. Nothing was written.',
135
+ approval_used: 'That approval was already used. Run pull-env again to ask once more.',
136
+ approval_unknown: 'That approval is unknown or its 10 minutes ran out. Run pull-env again to ask once more.',
137
+ approval_mismatch: 'That approval was for another tier or another key. Run pull-env again without --approval-token.',
138
+ reveal_rate_limited: 'This site\'s values were revealed 20 times in the last hour, the most allowed. Try again later.',
139
+ reveal_requests_rate_limited: '20 approvals were asked for this site in the last hour, the most allowed. Try again later.',
140
+ scope_denied: 'This folder\'s key is read-only, and revealing values needs a full key.',
141
+ };
142
+ return byCode[answer.code] || `${answer.message || 'The reveal was refused.'} (${answer.code})`;
143
+ }
144
+
145
+ /**
146
+ * Reveal one tier's values, waiting for a person's approval when the server
147
+ * asks for one. `call(args)` sends one project_secrets_reveal and returns the
148
+ * JSON-RPC answer. With `approvalToken` it resumes a wait a first run started
149
+ * (no new request is made). `onApprovalRequired(approval)` is told the link
150
+ * once, before the wait; with `wait: false` the pull stops there and throws
151
+ * approval_waiting (the token is in the error's `approval`).
152
+ */
153
+ export async function revealTier({
154
+ call,
155
+ projectId,
156
+ tier,
157
+ approvalToken,
158
+ wait = true,
159
+ onApprovalRequired,
160
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
161
+ now = () => Date.now(),
162
+ pollMs = APPROVAL_POLL_MS,
163
+ waitMs = APPROVAL_WAIT_MS,
164
+ }) {
165
+ const args = { project_id: projectId, tier };
166
+ let answer;
167
+ let token = approvalToken;
168
+ if (token) {
169
+ answer = readRevealAnswer(await call({ ...args, approval_token: token }));
170
+ } else {
171
+ answer = readRevealAnswer(await call(args));
172
+ if (answer.kind === 'approval_required') {
173
+ token = answer.approval.token;
174
+ await onApprovalRequired?.(answer.approval, answer.withheld);
175
+ if (!wait) {
176
+ const err = new EnvPullError('approval_waiting', 'Waiting for a person to approve. Run pull-env again with --approval-token once they have.');
177
+ err.approval = answer.approval;
178
+ throw err;
179
+ }
180
+ answer = { kind: 'pending' };
181
+ }
182
+ }
183
+ if (answer.kind === 'pending') {
184
+ // Only a call that carries a token can be pending; without one, a repeat would
185
+ // ask a person again on every poll.
186
+ if (!token) throw new EnvPullError('unexpected_answer', 'The reveal said it is waiting for an approval it never asked for. Pull again.');
187
+ const deadline = now() + waitMs;
188
+ let failures = 0;
189
+ while (answer.kind === 'pending') {
190
+ if (now() >= deadline) {
191
+ throw new EnvPullError('approval_timeout', 'Nobody approved within 10 minutes, so nothing was written. Run pull-env again to ask once more.');
192
+ }
193
+ await sleep(pollMs);
194
+ try {
195
+ answer = readRevealAnswer(await call({ ...args, approval_token: token }));
196
+ failures = 0;
197
+ } catch (err) {
198
+ failures += 1;
199
+ if (failures >= MAX_POLL_FAILURES) throw err;
200
+ answer = { kind: 'pending' };
201
+ }
202
+ }
203
+ }
204
+ if (answer.kind === 'values') return answer;
205
+ if (answer.kind === 'approval_required') {
206
+ throw new EnvPullError('unexpected_answer', 'The reveal asked for a new approval in the middle of a wait. Run pull-env again.');
207
+ }
208
+ throw new EnvPullError(answer.code || 'refused', refusalText(answer));
209
+ }
210
+
211
+ /**
212
+ * A value in the form the dotenv loaders read back exactly (dotenv parse plus
213
+ * dotenv-expand, as Next.js 15, Vite and Expo load .env files), or null when
214
+ * no form does:
215
+ * - nothing to quote or expand: written bare;
216
+ * - otherwise in single quotes, which dotenv keeps literally (line breaks
217
+ * included), with every `$` written `\$` so it is not expanded into
218
+ * another variable;
219
+ * - a value with a single quote goes in backticks the same way;
220
+ * - null for a value with both, with a carriage return or NUL (dotenv turns
221
+ * CR into LF), or ending in a backslash (dotenv reads `\'` as a quote
222
+ * inside the value and runs on to the next line).
223
+ */
224
+ export function encodeEnvValue(value) {
225
+ const v = String(value);
226
+ if (v === '') return '';
227
+ if (/[\r\0]/.test(v) || v.endsWith('\\')) return null;
228
+ if (/^[A-Za-z0-9_\-.,:/@+%~^=]+$/.test(v)) return v;
229
+ const escaped = v.replace(/\$/g, '\\$');
230
+ if (!v.includes("'")) return `'${escaped}'`;
231
+ if (!v.includes('`')) return `\`${escaped}\``;
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * The env file's text: the values, sorted, then what was not included and
237
+ * why, as comments. Returns the names written and the names left out.
238
+ */
239
+ export function renderEnvFile({ values, withheld = [], notApproved = [], tier, file }) {
240
+ const written = [];
241
+ const unwritable = [];
242
+ const lines = [
243
+ `# Written by \`hiveku-sync pull-env\` from Hiveku: this site's ${tier} values, revealed with a person's approval.`,
244
+ `# ${file} holds real values: keep it out of git and never paste it anywhere.`,
245
+ ];
246
+ for (const key of Object.keys(values).sort()) {
247
+ if (!KEY_RE.test(key)) {
248
+ unwritable.push({ key: oneLine(key, 80), why: 'not a variable name a .env file can hold' });
249
+ continue;
250
+ }
251
+ const encoded = encodeEnvValue(values[key]);
252
+ if (encoded === null) {
253
+ unwritable.push({ key, why: 'its value cannot be written to a .env file exactly (set it by hand)' });
254
+ continue;
255
+ }
256
+ lines.push(`${key}=${encoded}`);
257
+ written.push(key);
258
+ }
259
+ const notes = [
260
+ ...withheld.map((w) => ({ key: oneLine(w.key, 80), why: oneLine(w.why || w.reason || 'withheld') })),
261
+ ...notApproved.map((key) => ({ key: oneLine(key, 80), why: 'added after the person approved; pull again to include it' })),
262
+ ...unwritable,
263
+ ];
264
+ if (notes.length) {
265
+ lines.push('', '# Not included (their values are never shown; set them by hand if local dev needs them):');
266
+ for (const note of notes) lines.push(`# ${note.key}: ${note.why}`);
267
+ }
268
+ lines.push('');
269
+ return { text: lines.join('\n'), written, notes };
270
+ }
271
+
272
+ /**
273
+ * Write `text` to `target` with mode 0600, also over an existing file: a new
274
+ * file is created 0600 beside it and renamed over it.
275
+ */
276
+ export async function writeEnvFileSecure(target, text) {
277
+ const dir = path.dirname(target);
278
+ const temp = path.join(dir, `.${path.basename(target)}.${randomUUID()}.tmp`);
279
+ const handle = await fs.open(temp, 'wx', 0o600);
280
+ try {
281
+ await handle.writeFile(text, 'utf8');
282
+ await handle.chmod(0o600);
283
+ } finally {
284
+ await handle.close();
285
+ }
286
+ try {
287
+ await fs.rename(temp, target);
288
+ } catch (err) {
289
+ await fs.unlink(temp).catch(() => {});
290
+ throw err;
291
+ }
292
+ }
293
+
294
+ /** Add `file` to the folder's .gitignore unless a line there already covers it. */
295
+ export async function ensureGitignored(dir, file) {
296
+ const gi = path.join(dir, '.gitignore');
297
+ let text = '';
298
+ try {
299
+ text = await fs.readFile(gi, 'utf8');
300
+ } catch {
301
+ /* no .gitignore yet */
302
+ }
303
+ const covered = text.split(/\r?\n/).some((line) => {
304
+ const rule = line.trim();
305
+ return rule === file || rule === `/${file}` || rule === '.env*' || rule === '*.local' || rule === '.env*.local';
306
+ });
307
+ if (covered) return false;
308
+ const prefix = text && !text.endsWith('\n') ? '\n' : '';
309
+ await fs.writeFile(gi, `${text}${prefix}${file}\n`, 'utf8');
310
+ return true;
311
+ }
package/src/mcp.mjs CHANGED
@@ -68,6 +68,16 @@ export class HivekuMcpClient {
68
68
  return result;
69
69
  }
70
70
 
71
+ /**
72
+ * The tools/call result as it came, a tool error (isError) included, for a
73
+ * caller that reads the error's code itself: pull-env waits on
74
+ * approval_pending, which arrives as a tool error. A JSON-RPC error or an
75
+ * HTTP failure still throws.
76
+ */
77
+ async callToolResult(name, args = {}) {
78
+ return this._request('tools/call', { name, arguments: args });
79
+ }
80
+
71
81
  async callTool(name, args = {}) {
72
82
  const result = await this._request('tools/call', { name, arguments: args });
73
83
  if (result?.isError) {
package/src/push.mjs CHANGED
@@ -10,8 +10,9 @@
10
10
  * batched, per-batch-verified project_files_bulk_save. This is what
11
11
  * deploy_site actually reads — NOT project_commit's GitHub branch.
12
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.
13
+ * pull-env project_secrets_reveal → .env.local (or .env.staging.local /
14
+ * .env.production.local): the values the tier runs with, after a
15
+ * person approves (src/envPull.mjs).
15
16
  *
16
17
  * After a push whose files all landed, push saves them as ONE version of Your
17
18
  * site (or of the branch it pushed to): project_vcs_commit with NO files and
@@ -41,7 +42,16 @@ import {
41
42
  readProjectsManifest,
42
43
  writeProjectsManifest,
43
44
  } from './projects.mjs';
44
- import { writeFileAtomic, readEnv, loadDotenv } from './util.mjs';
45
+ import {
46
+ EnvPullError,
47
+ REVEAL_TOOL,
48
+ approvalTokenFrom,
49
+ ensureGitignored,
50
+ envFileForTier,
51
+ renderEnvFile,
52
+ revealTier,
53
+ writeEnvFileSecure,
54
+ } from './envPull.mjs';
45
55
  import {
46
56
  branchLabel,
47
57
  checkedVersionName,
@@ -674,106 +684,82 @@ export async function runDeploy({ slugOrId, environment, wait = false, profile,
674
684
 
675
685
  // ── pull-env ──────────────────────────────────────────────────────────────
676
686
 
677
- // Resolution mirrors the preview machine (project_secrets_set): _DEV wins for
678
- // development and is stripped to its base key; _PROD/_PRODUCTION are for
679
- // production, _STAGING for staging. Suffixes for OTHER tiers are skipped.
680
- const TIER_SUFFIX = {
681
- development: ['_DEV'],
682
- staging: ['_STAGING'],
683
- production: ['_PROD', '_PRODUCTION'],
684
- };
685
- const ALL_SUFFIXES = ['_DEV', '_STAGING', '_PROD', '_PRODUCTION'];
686
-
687
- export function resolveSecretsForTier(secrets, tier) {
688
- const want = TIER_SUFFIX[tier] || TIER_SUFFIX.development;
689
- const base = {};
690
- const overrides = {};
691
- for (const [key, value] of Object.entries(secrets || {})) {
692
- const suffix = ALL_SUFFIXES.find((s) => key.endsWith(s));
693
- if (!suffix) {
694
- base[key] = value;
695
- continue;
696
- }
697
- if (want.includes(suffix)) {
698
- overrides[key.slice(0, -suffix.length)] = value; // strip suffix → base name
699
- }
700
- // suffix for another tier → skip
701
- }
702
- return { ...base, ...overrides }; // tier override wins over the base key
703
- }
704
-
705
- function envFileForTier(tier) {
706
- if (tier === 'production') return '.env.production.local';
707
- if (tier === 'staging') return '.env.staging.local';
708
- return '.env.local';
709
- }
710
-
711
- export function serializeEnv(map) {
712
- const lines = [
713
- '# Generated by `hiveku-sync pull-env` from Hiveku project secrets.',
714
- '# Do NOT commit — this file is git-ignored.',
715
- '',
716
- ];
717
- for (const key of Object.keys(map).sort()) {
718
- let v = String(map[key] ?? '');
719
- // Quote if it contains whitespace, #, quotes, or newlines.
720
- if (/[\s#'"\n]/.test(v)) {
721
- v = `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
722
- }
723
- lines.push(`${key}=${v}`);
724
- }
725
- return lines.join('\n') + '\n';
726
- }
727
-
728
- export async function runPullEnv({ slug, profile, target = 'development' } = {}) {
687
+ /**
688
+ * `hiveku-sync pull-env [<slug>] [--target <tier>] [--yes]`: one tier's values
689
+ * into a local env file, through project_secrets_reveal (src/envPull.mjs),
690
+ * never project_secrets_list. The server resolves the tier (FOO_PROD over FOO,
691
+ * the dev/staging fallback), so nothing is resolved here.
692
+ *
693
+ * - development is the default; staging needs --target staging, and
694
+ * production needs --target production AND --yes;
695
+ * - a person approves on the link it prints, and it waits up to 10 minutes
696
+ * (--no-wait prints the link and stops with exit 3; --approval-token
697
+ * resumes that wait without asking again);
698
+ * - the file is written only when a value came back, mode 0600 even over an
699
+ * existing file, with the withheld names as comments; only names are
700
+ * printed.
701
+ *
702
+ * Returns { written, notIncluded, path, tier }, or { exit_code } when it
703
+ * stopped before writing.
704
+ */
705
+ export async function runPullEnv({ slug, profile, target = 'development', yes = false, approvalToken, wait = true, client: givenClient, log = console.log } = {}) {
729
706
  const tier = normalizeEnv(target);
730
707
  if (!['development', 'staging', 'production'].includes(tier)) {
731
708
  throw new Error(`Invalid --target "${target}" — use development | staging | production.`);
732
709
  }
710
+ if (tier === 'production' && yes !== true) {
711
+ throw new Error(
712
+ 'pull-env --target production writes the values production runs with to .env.production.local. ' +
713
+ 'Add --yes to confirm that is what you want.',
714
+ );
715
+ }
716
+ const token = approvalTokenFrom(approvalToken);
733
717
  const baseDir = resolveBaseDir();
734
- const { client } = await clientFromCwd({ profileOverride: profile });
735
- const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
718
+ const client = givenClient ?? (await clientFromCwd({ profileOverride: profile })).client;
719
+ const { slug: resolvedSlug, folder, info } = await findProjectFolder({ slug, baseDir });
736
720
  const projectId = info.project_id;
721
+ const file = envFileForTier(tier);
737
722
 
738
- const res = (await client.callToolJson('project_secrets_list', { project_id: projectId })).data;
739
- const secrets = res?.secrets || {};
740
- // Variables marked sensitive are write-only on the platform: the server OMITS them
741
- // from `secrets` and lists their names here. Without saying so, the .env file below
742
- // would be silently incomplete and the user would go debugging a phantom.
743
- const sensitiveKeys = Array.isArray(res?.sensitive_keys) ? res.sensitive_keys : [];
744
- const count = Object.keys(secrets).length;
745
- if (count === 0 && sensitiveKeys.length === 0) {
746
- console.log(`No secrets set on ${resolvedSlug}. Nothing to write.`);
747
- return { written: 0 };
748
- }
749
-
750
- const resolved = resolveSecretsForTier(secrets, tier);
751
- const outName = envFileForTier(tier);
752
- const outPath = join(folder, outName);
753
- await writeFileAtomic(outPath, serializeEnv(resolved));
754
-
755
- // Make sure the env file is git-ignored so it never gets pushed.
756
- const gitignorePath = join(folder, '.gitignore');
757
- let ignore = '';
723
+ const call = async (args) => ({ result: await client.callToolResult(REVEAL_TOOL, args) });
724
+ let answer;
758
725
  try {
759
- ignore = await readFile(gitignorePath, 'utf8');
760
- } catch {
761
- /* no .gitignore yet */
762
- }
763
- if (!ignore.split('\n').some((l) => l.trim() === outName)) {
764
- await writeFileAtomic(
765
- gitignorePath,
766
- (ignore && !ignore.endsWith('\n') ? ignore + '\n' : ignore) + outName + '\n',
767
- );
726
+ answer = await revealTier({
727
+ call,
728
+ projectId,
729
+ tier,
730
+ approvalToken: token,
731
+ wait,
732
+ onApprovalRequired: (approval) => {
733
+ log(`A person must approve revealing ${resolvedSlug}'s ${tier} values before ${file} is written.`);
734
+ log(`Approve here (anyone in the account who can see the site's variables): ${approval.approve_url}`);
735
+ log(
736
+ wait
737
+ ? 'Waiting up to 10 minutes for the approval...'
738
+ : `Then run: hiveku-sync pull-env ${resolvedSlug} --target ${tier}${tier === 'production' ? ' --yes' : ''} --approval-token ${approval.token}`,
739
+ );
740
+ },
741
+ });
742
+ } catch (err) {
743
+ if (err instanceof EnvPullError && err.code === 'approval_waiting') {
744
+ log(err.message);
745
+ return { written: 0, exit_code: 3, tier };
746
+ }
747
+ throw err;
768
748
  }
769
749
 
770
- console.log(`Wrote ${Object.keys(resolved).length} var${Object.keys(resolved).length !== 1 ? 's' : ''} → ${outName} (tier: ${tier}, from ${count} secret${count !== 1 ? 's' : ''}).`);
771
- if (sensitiveKeys.length > 0) {
772
- const names = sensitiveKeys.slice().sort();
773
- console.log(
774
- `Skipped ${names.length} sensitive var${names.length !== 1 ? 's' : ''}: ${names.join(', ')}.`,
775
- );
776
- console.log('These are write-only on Hiveku and cannot be read back. Set them by hand if your local build needs them.');
750
+ const { text, written, notes } = renderEnvFile({ values: answer.values, withheld: answer.withheld, notApproved: answer.notApproved, tier, file });
751
+ const notIncluded = notes.map((n) => `${n.key} (${n.why})`);
752
+ if (written.length === 0) {
753
+ // Never an empty file over a real one (the list-based pull did exactly that).
754
+ log(`No ${tier} value could be revealed for ${resolvedSlug}, so ${file} was left as it was.`);
755
+ if (notIncluded.length) log(`Not revealed: ${notIncluded.join('; ')}`);
756
+ return { written: 0, notIncluded: notes.map((n) => n.key), exit_code: 1, tier };
777
757
  }
778
- return { written: Object.keys(resolved).length, skippedSensitive: sensitiveKeys, path: outPath, tier };
758
+ const outPath = join(folder, file);
759
+ await writeEnvFileSecure(outPath, text);
760
+ // Make sure the env file is git-ignored so it never gets pushed.
761
+ await ensureGitignored(folder, file);
762
+ log(`Wrote ${written.length} ${tier} value${written.length === 1 ? '' : 's'} → ${file} (mode 0600; values not shown): ${written.join(', ')}`);
763
+ if (notIncluded.length) log(`Not included (set by hand if needed): ${notIncluded.join('; ')}`);
764
+ return { written: written.length, notIncluded: notes.map((n) => n.key), path: outPath, tier };
779
765
  }