@hiveku-apps/sync 0.5.3 → 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 +22 -6
- package/bin/hiveku-sync.mjs +19 -9
- package/package.json +1 -1
- package/src/codex-prompt-tools.json +11 -1
- package/src/codex.mjs +38 -7
- package/src/envPull.mjs +311 -0
- package/src/mcp.mjs +10 -0
- package/src/push.mjs +80 -94
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
|
|
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
|
|
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.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
|
|
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",
|
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
"project_vcs_resolve",
|
|
20
20
|
"project_vcs_pr_update",
|
|
21
21
|
"project_vcs_queue_add",
|
|
22
|
+
"project_secrets_set",
|
|
23
|
+
"project_secrets_delete",
|
|
24
|
+
"project_secrets_apply_to_preview",
|
|
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",
|
|
22
32
|
"hiveku_batch",
|
|
23
33
|
"project_checkpoint_restore",
|
|
24
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
|
|
313
|
-
// X-Hiveku-Client
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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" =
|
|
319
|
-
: `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" =
|
|
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)
|
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
|
}
|