@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 +32 -9
- package/bin/hiveku-sync.mjs +19 -9
- package/package.json +1 -1
- package/src/claude-md.mjs +45 -3
- package/src/codex-prompt-tools.json +7 -1
- package/src/codex.mjs +40 -8
- package/src/envPull.mjs +311 -0
- package/src/mcp.mjs +10 -0
- package/src/push.mjs +80 -94
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
|
|
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
|
|
134
|
-
Hiveku Codex plugin 0.
|
|
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`
|
|
299
|
-
|
|
300
|
-
|
|
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"
|
|
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.
|
package/bin/hiveku-sync.mjs
CHANGED
|
@@ -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
|
|
134
|
-
[--target <env>]
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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.
|
|
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
|
|
66
|
-
anything else in this file disagrees with them,
|
|
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
|
|
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
|
|
313
|
-
// X-Hiveku-Client
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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" =
|
|
319
|
-
: `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" =
|
|
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)
|
package/src/envPull.mjs
ADDED
|
@@ -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
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
735
|
-
const { slug: resolvedSlug,
|
|
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
|
|
739
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
);
|
|
776
|
-
|
|
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
|
-
|
|
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
|
}
|