@hiveku-apps/sync 0.5.4 → 0.5.5

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
@@ -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
 
@@ -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.5",
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",
@@ -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
@@ -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);
@@ -309,14 +338,16 @@ function codexTomlRegion({ baseUrl, profile, apiKey, userTables = [] }) {
309
338
  // MCP server (Codex config reference, "mcp_servers.<id>.http_headers"; the
310
339
  // docs' example is `http_headers = { "X-Figma-Region" = "us-east-1" }`, next
311
340
  // 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.
341
+ // client header rides in the same inline table as an inlined key.
342
+ // X-Hiveku-Client is hiveku-sync/<version> (codexClientHeader): the
343
+ // declaration that this block asks before a reveal, which the MCP server's
344
+ // memory log still shows as "via Codex"; it is never authentication. The key
345
+ // and the URL come from .env, so they are escaped: a quote in either would
346
+ // otherwise end the string early and leave the block itself unreadable.
347
+ const client = tomlString(codexClientHeader(userTables));
317
348
  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" }`;
349
+ ? `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)}, "X-Hiveku-Client" = ${client} }`
350
+ : `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" = ${client} }`;
320
351
  // default_tools_approval_mode = "approve": this folder exists so the agent
321
352
  // can operate the account — per-call approval prompts would also cancel
322
353
  // 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
  }