@hiveku-apps/sync 0.3.2 → 0.3.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 +24 -0
- package/bin/hiveku-sync.mjs +7 -0
- package/package.json +1 -1
- package/src/account-memory.mjs +240 -0
- package/src/codex.mjs +19 -2
- package/src/init.mjs +21 -0
- package/src/projects.mjs +244 -34
- package/src/pull.mjs +55 -7
- package/src/push.mjs +37 -12
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ Local sync CLI for a Hiveku account. Pairs with the Hiveku MCP server at
|
|
|
8
8
|
2. **`hiveku-sync pull`** — fetch the account's memory/rules/skills via the
|
|
9
9
|
`memory_list` MCP tool and write each entry as a Markdown file with
|
|
10
10
|
YAML frontmatter (name, type, domain, department, version, updated_at).
|
|
11
|
+
It also writes the account memory (see "Account memory" below) to
|
|
12
|
+
`hiveku-data/account/ACCOUNT_MEMORY.md`, read-only.
|
|
11
13
|
|
|
12
14
|
The goal: open one folder per Hiveku tenant in VS Code with Claude Code,
|
|
13
15
|
and the local `.md` files become the source-of-truth Claude reads. Tools
|
|
@@ -182,3 +184,25 @@ For **projects**, `hiveku-sync push` / `deploy` write straight back to Hiveku
|
|
|
182
184
|
(see "Coder projects" above). For **memory/rules/skills**, `pull` is read-only —
|
|
183
185
|
persist changes with the `memory_create` / `memory_update` / `memory_delete` MCP
|
|
184
186
|
tools through Claude, then `hiveku-sync pull` to refresh the local copies.
|
|
187
|
+
|
|
188
|
+
## Account memory
|
|
189
|
+
|
|
190
|
+
The account memory is the business facts every Hiveku department agent reads.
|
|
191
|
+
Owners and admins edit it on the Hiveku dashboard, under Account memory
|
|
192
|
+
(`https://app.hiveku.com/<account id>/dashboard/memory`); no tool sets it.
|
|
193
|
+
|
|
194
|
+
`pull` reads it with `account_memory_get` and writes one file,
|
|
195
|
+
`hiveku-data/account/ACCOUNT_MEMORY.md`, the same file, byte for byte, that the
|
|
196
|
+
Hiveku plugin and the VS Code extension write. The file is read-only on disk and
|
|
197
|
+
starts with a note saying where it is edited, with the link (the unscoped
|
|
198
|
+
`https://app.hiveku.com/dashboard/memory` when the account id cannot be read),
|
|
199
|
+
and that it is internal to the team. The owners' text comes first, then the suggestions
|
|
200
|
+
agents and team members made that an owner has not reviewed yet, each with who
|
|
201
|
+
suggested it and when. The two account-memory rows (`account` and
|
|
202
|
+
`account-suggestions`) are never written under `memory/`, even when an older
|
|
203
|
+
server still lists them.
|
|
204
|
+
|
|
205
|
+
Nothing uploads this file: `push`, `commit` and `status` leave any
|
|
206
|
+
`hiveku-data/account/` folder out, and the next `pull` replaces it. To add a
|
|
207
|
+
fact, suggest it with the `account_memory_append` tool; an owner keeps or
|
|
208
|
+
removes it on the dashboard.
|
package/bin/hiveku-sync.mjs
CHANGED
|
@@ -189,6 +189,13 @@ async function main() {
|
|
|
189
189
|
for (const [type, count] of Object.entries(summary.by_type)) {
|
|
190
190
|
console.log(` ${type}: ${count}`);
|
|
191
191
|
}
|
|
192
|
+
if (summary.account_memory) {
|
|
193
|
+
const am = summary.account_memory;
|
|
194
|
+
console.log(
|
|
195
|
+
` account memory: ${am.file} (read-only; ${am.version > 0 ? `version ${am.version}` : 'not written yet'}, ` +
|
|
196
|
+
`${am.suggestions} suggestion${am.suggestions === 1 ? '' : 's'} waiting). Owners edit it on the dashboard.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
192
199
|
if (summary.removed_remotely.length > 0) {
|
|
193
200
|
console.log('\nNote: these files were present in the last sync but are no longer in Olympus:');
|
|
194
201
|
for (const f of summary.removed_remotely) console.log(` ${f}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hiveku-apps/sync",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account memory, pulled read-only.
|
|
3
|
+
*
|
|
4
|
+
* The account memory is the business facts every Hiveku department agent reads.
|
|
5
|
+
* It lives in two rows of account_ai_memory, and neither is a department:
|
|
6
|
+
*
|
|
7
|
+
* domain `account` the owner's document (owners and admins edit
|
|
8
|
+
* it on the dashboard, nowhere else);
|
|
9
|
+
* domain `account-suggestions` one-line suggestions from agents, MCP clients
|
|
10
|
+
* and team members, waiting for an owner's
|
|
11
|
+
* Keep or Remove (JSON Lines).
|
|
12
|
+
*
|
|
13
|
+
* Neither row may land in the memory/ tree as if it were a department's
|
|
14
|
+
* document: `account-suggestions` would be raw JSON Lines, and a local edit to
|
|
15
|
+
* either would look like something `memory_update` could write back, which the
|
|
16
|
+
* server refuses. So `hiveku-sync pull` classifies both domains explicitly and
|
|
17
|
+
* writes one read-only file instead, in the same place the Hiveku plugin does:
|
|
18
|
+
*
|
|
19
|
+
* hiveku-data/account/ACCOUNT_MEMORY.md
|
|
20
|
+
*
|
|
21
|
+
* from `account_memory_get`. Nothing in hiveku-sync ever uploads that file:
|
|
22
|
+
* there is no tool that sets the account memory, and `push` leaves the folder
|
|
23
|
+
* out (see isAccountMemoryPath).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { chmod } from 'node:fs/promises';
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
28
|
+
|
|
29
|
+
import { writeFileAtomic } from './util.mjs';
|
|
30
|
+
|
|
31
|
+
export const ACCOUNT_MEMORY_DOMAIN = 'account';
|
|
32
|
+
export const ACCOUNT_SUGGESTIONS_DOMAIN = 'account-suggestions';
|
|
33
|
+
export const ACCOUNT_MEMORY_DOMAINS = new Set([ACCOUNT_MEMORY_DOMAIN, ACCOUNT_SUGGESTIONS_DOMAIN]);
|
|
34
|
+
|
|
35
|
+
/** Folder and file, relative to the account folder (same layout as the plugin). */
|
|
36
|
+
export const ACCOUNT_DATA_DIR = 'hiveku-data/account';
|
|
37
|
+
export const ACCOUNT_MEMORY_FILE = `${ACCOUNT_DATA_DIR}/ACCOUNT_MEMORY.md`;
|
|
38
|
+
|
|
39
|
+
export const APP_URL = 'https://app.hiveku.com';
|
|
40
|
+
|
|
41
|
+
const UUID_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 function isAccountMemoryDomain(domain) {
|
|
44
|
+
return typeof domain === 'string' && ACCOUNT_MEMORY_DOMAINS.has(domain.trim());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where a memory_list row belongs. The two account domains are named
|
|
49
|
+
* explicitly, before any folder or department tag is looked at: they carry no
|
|
50
|
+
* department tag, so a generic rule would file them under memory/ (or, in a
|
|
51
|
+
* by-department layout, invent an "account" department).
|
|
52
|
+
*
|
|
53
|
+
* { kind: 'account' } the owner document (written from account_memory_get)
|
|
54
|
+
* { kind: 'account-suggestions' } the suggestions row (listed under the document)
|
|
55
|
+
* { kind: 'knowledge', folder } everything else, in its type's folder
|
|
56
|
+
*/
|
|
57
|
+
export function classifyMemoryEntry(entry, folder) {
|
|
58
|
+
const domain = typeof entry?.domain === 'string' ? entry.domain.trim() : '';
|
|
59
|
+
if (domain === ACCOUNT_MEMORY_DOMAIN) return { kind: 'account' };
|
|
60
|
+
if (domain === ACCOUNT_SUGGESTIONS_DOMAIN) return { kind: 'account-suggestions' };
|
|
61
|
+
return { kind: 'knowledge', folder };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True for a path (relative, forward or back slashes) inside the pulled
|
|
66
|
+
* account-memory folder, wherever that folder sits. `push` drops these paths,
|
|
67
|
+
* so the read-only copy can never be uploaded as site code.
|
|
68
|
+
*/
|
|
69
|
+
export function isAccountMemoryPath(relPath) {
|
|
70
|
+
const p = String(relPath || '').replace(/\\/g, '/').replace(/^\.?\//, '');
|
|
71
|
+
return p === ACCOUNT_DATA_DIR || p.startsWith(`${ACCOUNT_DATA_DIR}/`) || p.includes(`/${ACCOUNT_DATA_DIR}/`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const ACCOUNT_MEMORY_TOOL = 'account_memory_get';
|
|
75
|
+
|
|
76
|
+
/** The builder's dashboard page (src/app/dashboard/memory, scoped mirror under /[accountId]). */
|
|
77
|
+
const DASHBOARD_PATH = 'dashboard/memory';
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* ONE FILE, THREE WRITERS. The Claude Code plugin (lib/account-memory.mjs),
|
|
81
|
+
* the VS Code extension (src/accountMemory.ts) and hiveku-sync all write
|
|
82
|
+
* hiveku-data/account/ACCOUNT_MEMORY.md, and the plugin reads its front matter
|
|
83
|
+
* back (`fetched_at`, for `pull --list` and `--stale`). So everything from here
|
|
84
|
+
* to writeReadOnly is a port of the plugin's normalizeAccountMemory,
|
|
85
|
+
* accountMemoryDashboardUrl and renderAccountMemoryFile, and
|
|
86
|
+
* test/account-memory.test.mjs compares the two byte for byte whenever a plugin
|
|
87
|
+
* checkout sits beside this repo. Change them together.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The page where owners and admins edit it. Account-scoped when the account id
|
|
92
|
+
* is a real UUID (the scoped route opens on the right account for a person in
|
|
93
|
+
* several); the unscoped page otherwise, never a half-built URL.
|
|
94
|
+
*/
|
|
95
|
+
export function accountMemoryDashboardUrl(accountId, appUrl = APP_URL) {
|
|
96
|
+
const base = String(appUrl || APP_URL).replace(/\/+$/, '');
|
|
97
|
+
return typeof accountId === 'string' && UUID_RE.test(accountId)
|
|
98
|
+
? `${base}/${accountId.toLowerCase()}/${DASHBOARD_PATH}`
|
|
99
|
+
: `${base}/${DASHBOARD_PATH}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The tool result, checked. account_memory_get returns the builder's
|
|
104
|
+
* `{ data: { content, version, updated_at, bytes, suggestions, suggestions_version,
|
|
105
|
+
* injected, truncated } }`. Anything else is an error, not an empty memory:
|
|
106
|
+
* writing "nothing written yet" over a real document because the shape moved
|
|
107
|
+
* would be a lie on disk.
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeAccountMemory(payload) {
|
|
110
|
+
const inner = payload && typeof payload === 'object' && !Array.isArray(payload) && 'data' in payload ? payload.data : payload;
|
|
111
|
+
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) {
|
|
112
|
+
throw new Error(`${ACCOUNT_MEMORY_TOOL} returned an unexpected shape (no object)`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof inner.content !== 'string' || !Number.isFinite(Number(inner.version))) {
|
|
115
|
+
throw new Error(`${ACCOUNT_MEMORY_TOOL} returned an unexpected shape (no content or version)`);
|
|
116
|
+
}
|
|
117
|
+
const suggestions = Array.isArray(inner.suggestions) ? inner.suggestions : [];
|
|
118
|
+
return {
|
|
119
|
+
content: inner.content,
|
|
120
|
+
version: Number(inner.version),
|
|
121
|
+
updated_at: typeof inner.updated_at === 'string' ? inner.updated_at : null,
|
|
122
|
+
suggestions: suggestions
|
|
123
|
+
.filter((s) => s && typeof s === 'object' && typeof s.text === 'string' && s.text.trim())
|
|
124
|
+
.map((s) => ({
|
|
125
|
+
id: typeof s.id === 'string' ? s.id : '',
|
|
126
|
+
at: typeof s.at === 'string' ? s.at : '',
|
|
127
|
+
source: typeof s.source === 'string' && s.source.trim() ? s.source : 'Unknown',
|
|
128
|
+
text: s.text,
|
|
129
|
+
})),
|
|
130
|
+
suggestions_version: Number.isFinite(Number(inner.suggestions_version)) ? Number(inner.suggestions_version) : 0,
|
|
131
|
+
truncated: inner.truncated === true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** "2026-09-23 14:02 UTC", or the raw value when it is not a date. */
|
|
136
|
+
function readableTime(value) {
|
|
137
|
+
if (!value) return 'unknown time';
|
|
138
|
+
const d = new Date(value);
|
|
139
|
+
if (Number.isNaN(d.getTime())) return String(value);
|
|
140
|
+
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One line, no line breaks: a suggestion is one line on the server too. */
|
|
144
|
+
function oneLine(value) {
|
|
145
|
+
return String(value).replace(/[\r\n\u2028\u2029]+/g, ' ').trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function yamlString(value) {
|
|
149
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The file text. Plain language: the person reading it may be the owner.
|
|
154
|
+
* Held suggestions (from the agents that talk to customers, and onboarding)
|
|
155
|
+
* are never in the tool's answer, so they are never here either.
|
|
156
|
+
*/
|
|
157
|
+
export function renderAccountMemoryFile({ memory, accountId, fetchedAt, appUrl = APP_URL }) {
|
|
158
|
+
const editUrl = accountMemoryDashboardUrl(accountId, appUrl);
|
|
159
|
+
const fm = [
|
|
160
|
+
'---',
|
|
161
|
+
`read_only: true`,
|
|
162
|
+
`source_tool: ${ACCOUNT_MEMORY_TOOL}`,
|
|
163
|
+
`version: ${memory.version}`,
|
|
164
|
+
...(memory.updated_at ? [`updated_at: ${yamlString(memory.updated_at)}`] : []),
|
|
165
|
+
`suggestions: ${memory.suggestions.length}`,
|
|
166
|
+
`suggestions_version: ${memory.suggestions_version}`,
|
|
167
|
+
`fetched_at: ${yamlString(fetchedAt)}`,
|
|
168
|
+
`edit_url: ${yamlString(editUrl)}`,
|
|
169
|
+
'---',
|
|
170
|
+
'',
|
|
171
|
+
];
|
|
172
|
+
const lines = [
|
|
173
|
+
'# Account memory',
|
|
174
|
+
'',
|
|
175
|
+
'> This is a read-only copy of the account memory, the facts about the business that every',
|
|
176
|
+
'> Hiveku department agent reads. Owners and admins edit it on the Hiveku dashboard:',
|
|
177
|
+
`> ${editUrl}`,
|
|
178
|
+
'>',
|
|
179
|
+
'> Changes made to this file are not saved to Hiveku. The next pull replaces this file, and',
|
|
180
|
+
'> nothing uploads it. To add a fact, suggest it with account_memory_append (an owner or admin',
|
|
181
|
+
'> reviews it on the dashboard), or ask an owner or admin to change the document there.',
|
|
182
|
+
'> It is internal to the team: do not quote it to customers.',
|
|
183
|
+
'',
|
|
184
|
+
];
|
|
185
|
+
if (memory.version > 0 && memory.content.trim()) {
|
|
186
|
+
lines.push(`Version ${memory.version}, last changed ${readableTime(memory.updated_at)}.`);
|
|
187
|
+
if (memory.truncated) {
|
|
188
|
+
lines.push(
|
|
189
|
+
'Agents see a shortened version: with the suggestions, it is longer than the 8,000 characters they read.',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
lines.push('', '---', '', memory.content.replace(/\s+$/, ''), '', '---', '');
|
|
193
|
+
} else {
|
|
194
|
+
lines.push('Nothing has been written yet. An owner or admin can start it on the dashboard.', '');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
lines.push('# Suggestions from agents, not reviewed yet', '');
|
|
198
|
+
if (memory.suggestions.length) {
|
|
199
|
+
lines.push(
|
|
200
|
+
'These lines are not part of the account memory until an owner or admin keeps them on the dashboard.',
|
|
201
|
+
'',
|
|
202
|
+
);
|
|
203
|
+
for (const s of memory.suggestions) {
|
|
204
|
+
lines.push(`- ${oneLine(s.text)} (suggested by ${oneLine(s.source)}, ${readableTime(s.at)})`);
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
lines.push('None waiting.');
|
|
208
|
+
}
|
|
209
|
+
lines.push('');
|
|
210
|
+
return fm.join('\n') + lines.join('\n');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Write the file read-only (0444), replacing any earlier read-only copy. */
|
|
214
|
+
export async function writeReadOnly(path, contents) {
|
|
215
|
+
if (existsSync(path)) {
|
|
216
|
+
// A 0444 file can be renamed over on POSIX, but not on Windows; open it up
|
|
217
|
+
// for the replace, then lock it again.
|
|
218
|
+
await chmod(path, 0o644).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
await writeFileAtomic(path, contents);
|
|
221
|
+
await chmod(path, 0o444);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The account id for the dashboard link, from get_account_info. The key is
|
|
226
|
+
* pinned to one account, so the first row is it. Any failure (a narrowed
|
|
227
|
+
* profile without the tool, an older server) gives null, and the link falls
|
|
228
|
+
* back to the unscoped dashboard page.
|
|
229
|
+
*/
|
|
230
|
+
export async function resolveAccountId(client) {
|
|
231
|
+
try {
|
|
232
|
+
const result = await client.callToolJson('get_account_info', {});
|
|
233
|
+
const data = result?.data ?? result;
|
|
234
|
+
const row = Array.isArray(data) ? data[0] : data;
|
|
235
|
+
const id = typeof row?.id === 'string' ? row.id.trim() : '';
|
|
236
|
+
return UUID_RE.test(id) ? id : null;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/codex.mjs
CHANGED
|
@@ -68,6 +68,11 @@ invoked by name) and this file.
|
|
|
68
68
|
|
|
69
69
|
Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
|
|
70
70
|
- **Verify identity before ANY write:** \`get_account_info\` must return THIS account.
|
|
71
|
+
- **Context and memory.** Call \`account_context_get({ domain })\` first, before any copy, plan or analysis.
|
|
72
|
+
The account memory is read-only to you: owners edit it on the Hiveku dashboard, the local copy is
|
|
73
|
+
\`hiveku-data/account/ACCOUNT_MEMORY.md\` (never edit or upload it); suggest one line with \`account_memory_append\`.
|
|
74
|
+
Department memory is ONE document per department: read it (\`memory_list({ domain })\`), merge, then send the
|
|
75
|
+
whole document with \`memory_update({ memory_id, content })\`. Never create-then-overwrite on a 409.
|
|
71
76
|
- **You are NOT the only writer.** Others push to these same projects while you work. Check what is
|
|
72
77
|
current BEFORE you start (\`project_version_log\`) and AGAIN before you push (\`project_files_status\`
|
|
73
78
|
— \`changed\` = they edited it, \`only_remote\` = they added files you lack). Before any tree-replace
|
|
@@ -82,6 +87,10 @@ Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
|
|
|
82
87
|
attributed to the authenticated user (\`crm_list_users\`).
|
|
83
88
|
- **Every completed task ends with an "Owner update"** — 2–4 calm, plain-language sentences a busy owner
|
|
84
89
|
can skim: benefit first, no alarm vocabulary, accurate.
|
|
90
|
+
- **Hiveku itself in your way?** A Hiveku tool still failing after one sensible retry → \`hiveku_report_issue\`;
|
|
91
|
+
a capability no tool offers (search \`hiveku_docs_search\` first) → \`hiveku_request_feature\`. No secrets or personal data.
|
|
92
|
+
- **Tell the user only if it changes what they get:** one or two calm sentences — flagged to the Hiveku team (give the
|
|
93
|
+
ref), the team is quick to fix these, you'll let them know when it's sorted. No error codes, blame or promised times.
|
|
85
94
|
- Video generation is paid + capped — \`marketing_generate_video\` with \`dry_run: true\` first.
|
|
86
95
|
|
|
87
96
|
Discover tools with \`hiveku_docs_search\` / \`hiveku_docs_get\` and \`hiveku_playbooks_list\` /
|
|
@@ -97,9 +106,17 @@ function codexTomlRegion({ baseUrl, profile, apiKey }) {
|
|
|
97
106
|
// Prefer an inlined key (self-contained, no env setup needed; the file is
|
|
98
107
|
// git-ignored). Fall back to the OLYMPUS_API_KEY env var when we can't
|
|
99
108
|
// resolve a real key at scaffold time (e.g. `init` before the user pastes it).
|
|
109
|
+
//
|
|
110
|
+
// http_headers is Codex's map of static HTTP headers for a streamable HTTP
|
|
111
|
+
// MCP server (Codex config reference, "mcp_servers.<id>.http_headers"; the
|
|
112
|
+
// docs' example is `http_headers = { "X-Figma-Region" = "us-east-1" }`, next
|
|
113
|
+
// to bearer_token_env_var). TOML allows the key once per table, so the
|
|
114
|
+
// client label rides in the same inline table as an inlined key.
|
|
115
|
+
// X-Hiveku-Client: codex is what the MCP server's memory log shows as
|
|
116
|
+
// "via Codex"; it is a label, never authentication.
|
|
100
117
|
const auth = apiKey
|
|
101
|
-
? `http_headers = { "Authorization" = "Bearer ${apiKey}" }`
|
|
102
|
-
: `bearer_token_env_var = "OLYMPUS_API_KEY"`;
|
|
118
|
+
? `http_headers = { "Authorization" = "Bearer ${apiKey}", "X-Hiveku-Client" = "codex" }`
|
|
119
|
+
: `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" = "codex" }`;
|
|
103
120
|
// default_tools_approval_mode = "approve": this folder exists so the agent
|
|
104
121
|
// can operate the account — per-call approval prompts would also auto-cancel
|
|
105
122
|
// every tool call in headless `codex exec` (CI/cron), this lane's core use.
|
package/src/init.mjs
CHANGED
|
@@ -64,6 +64,9 @@ exposes the **${profile}** profile of Olympus tools.
|
|
|
64
64
|
- \`rules/\` — synced agent rules
|
|
65
65
|
- \`skills/\` — synced agent skills
|
|
66
66
|
- \`commands/\`, \`agents/\`, \`identity/\` — other memory types
|
|
67
|
+
- \`hiveku-data/account/ACCOUNT_MEMORY.md\` — the account memory (the business
|
|
68
|
+
facts every department agent reads), **read-only**: owners and admins edit it on
|
|
69
|
+
the Hiveku dashboard under Account memory, and nothing uploads this file
|
|
67
70
|
- \`projects/<slug>/\` — coder project source pulled via \`hiveku-sync pull-project\`
|
|
68
71
|
- \`.hiveku/manifest.json\` — memory last-sync record
|
|
69
72
|
- \`.hiveku/projects.json\` — project ID ↔ folder mapping
|
|
@@ -81,6 +84,16 @@ changes (new skill, updated rule, etc.), call the corresponding MCP tool:
|
|
|
81
84
|
commands, agents, identity entries
|
|
82
85
|
- \`memory_delete\` — to remove an entry
|
|
83
86
|
|
|
87
|
+
\`memory_update\` replaces the whole entry. Each department's memory is ONE
|
|
88
|
+
document, so read the current one with \`memory_list({ domain })\`, merge your
|
|
89
|
+
change into its full text, and send the whole document. Use \`memory_create\`
|
|
90
|
+
only when none exists; a 409 means one does, so read and merge, never overwrite.
|
|
91
|
+
|
|
92
|
+
The account memory is the exception: no tool sets it. Read it with
|
|
93
|
+
\`account_memory_get\`, suggest one line with \`account_memory_append\` (an owner
|
|
94
|
+
keeps or removes it), and tell the user that owners and admins edit it on the
|
|
95
|
+
Hiveku dashboard under Account memory.
|
|
96
|
+
|
|
84
97
|
Then run \`hiveku-sync pull\` to refresh the local copies.
|
|
85
98
|
|
|
86
99
|
## Coder projects (pull / status / push)
|
|
@@ -112,6 +125,14 @@ You can also ask Claude in this workspace to:
|
|
|
112
125
|
Claude has the \`project_files_list\`, \`project_file_get\`, \`project_files_status\`,
|
|
113
126
|
and \`project_commit\` MCP tools available under the **${profile}** profile.
|
|
114
127
|
|
|
128
|
+
## When Hiveku itself gets in your way
|
|
129
|
+
|
|
130
|
+
- A Hiveku tool still failing after one sensible retry → \`hiveku_report_issue\`; a capability
|
|
131
|
+
no tool offers (search \`hiveku_docs_search\` first) → \`hiveku_request_feature\`. No secrets or personal data.
|
|
132
|
+
- Tell the user only if it changes what they get: one or two calm sentences — flagged to the Hiveku
|
|
133
|
+
team (give the ref), the team is quick to fix these, you'll let them know when it's sorted. No
|
|
134
|
+
error codes, blame or promised times.
|
|
135
|
+
|
|
115
136
|
## Tenant scope
|
|
116
137
|
|
|
117
138
|
The API key in \`.env\` is **pinned to one Hiveku account**. You cannot switch
|
package/src/projects.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
writeFileAtomic,
|
|
20
20
|
readJson,
|
|
21
21
|
} from './util.mjs';
|
|
22
|
+
import { isAccountMemoryPath } from './account-memory.mjs';
|
|
22
23
|
// NOTE: push.mjs imports helpers from this module; the cycle is safe because
|
|
23
24
|
// both sides reference the imports only inside functions (never at module eval).
|
|
24
25
|
import { computePushPlan } from './push.mjs';
|
|
@@ -47,8 +48,37 @@ const RUN_HINTS = {
|
|
|
47
48
|
'python-lambda': 'python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt',
|
|
48
49
|
expo: 'npm install && npx expo start',
|
|
49
50
|
static: 'npx serve .',
|
|
51
|
+
// The server's name for a static site is `static-html` (site_create's enum);
|
|
52
|
+
// `static` above is kept for rows created before that name settled.
|
|
53
|
+
'static-html': 'npx serve .',
|
|
54
|
+
external:
|
|
55
|
+
'# External site: there is no code to run. Manage it from the Hiveku dashboard (Webflow sites: /dashboard/<project id>/webflow) or the webflow_* MCP tools.',
|
|
50
56
|
};
|
|
51
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Project types with no code tree to mirror. Mirrors the builder's
|
|
60
|
+
* NO_LOCAL_CODE_TYPES minus python-lambda, which hiveku-sync still pulls and
|
|
61
|
+
* ships with the run hint above; folding it in is a separate decision.
|
|
62
|
+
*/
|
|
63
|
+
const EXTERNAL_TYPES = new Set(['external']);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Refuse an external project (a site hosted on Webflow, WordPress or any other
|
|
67
|
+
* host that Hiveku only tracks) before any file, VCS or deploy call. Accepts a
|
|
68
|
+
* project_get/sites_list row (`id`) or a projects.json manifest row
|
|
69
|
+
* (`project_id`); both carry `project_type`, the former `external_platform`.
|
|
70
|
+
*/
|
|
71
|
+
export function refuseExternal(project) {
|
|
72
|
+
if (!project || !EXTERNAL_TYPES.has(String(project.project_type || '').toLowerCase())) return;
|
|
73
|
+
const id = project.id || project.project_id;
|
|
74
|
+
const platform = project.external_platform ? `, platform=${project.external_platform}` : '';
|
|
75
|
+
throw new Error(
|
|
76
|
+
`"${project.name || id}" is an external site (project_type=external${platform}). ` +
|
|
77
|
+
`hiveku-sync mirrors code projects only; edit this site in the Hiveku dashboard ` +
|
|
78
|
+
`(/dashboard/${id}/webflow for Webflow) or through the webflow_* MCP tools.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
export function sha256(content) {
|
|
53
83
|
return createHash('sha256').update(content).digest('hex');
|
|
54
84
|
}
|
|
@@ -89,6 +119,12 @@ async function listAllRemoteFiles(client, projectId) {
|
|
|
89
119
|
return all;
|
|
90
120
|
}
|
|
91
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Every local file push, commit and status consider. The pulled account memory
|
|
124
|
+
* (hiveku-data/account/) is left out wherever it sits: it is a read-only copy
|
|
125
|
+
* that owners edit on the dashboard, and nothing may upload it, not even when
|
|
126
|
+
* an account folder and a project folder are the same folder.
|
|
127
|
+
*/
|
|
92
128
|
export async function walkLocalFiles(rootDir) {
|
|
93
129
|
const out = [];
|
|
94
130
|
async function recurse(dir) {
|
|
@@ -102,6 +138,7 @@ export async function walkLocalFiles(rootDir) {
|
|
|
102
138
|
if (entry.name.startsWith('.')) continue;
|
|
103
139
|
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
104
140
|
const full = join(dir, entry.name);
|
|
141
|
+
if (isAccountMemoryPath(relative(rootDir, full).split(sep).join('/'))) continue;
|
|
105
142
|
if (entry.isDirectory()) {
|
|
106
143
|
await recurse(full);
|
|
107
144
|
} else if (entry.isFile()) {
|
|
@@ -124,10 +161,21 @@ async function buildLocalManifest(rootDir) {
|
|
|
124
161
|
return manifest;
|
|
125
162
|
}
|
|
126
163
|
|
|
127
|
-
function runHintForProjectType(type) {
|
|
164
|
+
export function runHintForProjectType(type) {
|
|
128
165
|
return RUN_HINTS[type] || `# Unknown project_type "${type}". Inspect package.json/requirements.txt to find the dev command.`;
|
|
129
166
|
}
|
|
130
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The MCP client and account folder an entry point works with. Tests inject
|
|
170
|
+
* `client` (a fake with callToolJson) and `baseDir`; the CLI passes neither and
|
|
171
|
+
* gets the .env-configured client with cwd as the account folder.
|
|
172
|
+
*/
|
|
173
|
+
async function resolveClient({ client, profile, baseDir } = {}) {
|
|
174
|
+
if (client) return { client, baseDir: baseDir || process.cwd() };
|
|
175
|
+
const resolved = await clientFromCwd({ profileOverride: profile });
|
|
176
|
+
return { client: resolved.client, baseDir: baseDir || resolved.baseDir };
|
|
177
|
+
}
|
|
178
|
+
|
|
131
179
|
export async function readProjectsManifest(baseDir) {
|
|
132
180
|
const data = await readJson(join(baseDir, PROJECTS_MANIFEST));
|
|
133
181
|
return data && typeof data === 'object' ? data : { projects: {} };
|
|
@@ -137,45 +185,202 @@ export async function writeProjectsManifest(baseDir, manifest) {
|
|
|
137
185
|
await writeFileAtomic(join(baseDir, PROJECTS_MANIFEST), JSON.stringify(manifest, null, 2));
|
|
138
186
|
}
|
|
139
187
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
188
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
189
|
+
|
|
190
|
+
// sites_list and list_projects both cap `limit` at 100 server-side.
|
|
191
|
+
const LIST_PAGE_LIMIT = 100;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Two id spaces, two tool families (S2, confirmed live 2026-09-10):
|
|
195
|
+
*
|
|
196
|
+
* website_projects — the code this CLI pulls, pushes and deploys.
|
|
197
|
+
* Read with `project_get` (one) and `sites_list` (all).
|
|
198
|
+
* pm_projects — project-MANAGEMENT rows (tasks, owners, status), with
|
|
199
|
+
* project_type seo|ppc|marketing|website|... and their
|
|
200
|
+
* own ids. Read with `get_project` and `list_projects`.
|
|
201
|
+
* A PM row may point at a website project through
|
|
202
|
+
* `website_project_id` / `linked_site`.
|
|
203
|
+
*
|
|
204
|
+
* Until 0.3.3 resolveProject used the PM tools for both shapes: a website
|
|
205
|
+
* UUID got get_project's 404 back as the "project", and a slug got a PM row
|
|
206
|
+
* whose id then 404'd on every file call and whose project_type "website"
|
|
207
|
+
* stamped an "Unknown project_type" RUN.md. Every lookup below lands on a
|
|
208
|
+
* website_projects row, which carries the project_type and external_platform
|
|
209
|
+
* that refuseExternal reads.
|
|
210
|
+
*/
|
|
211
|
+
|
|
212
|
+
/** Does this error (thrown or returned) mean the row does not exist, as opposed to any other failure? */
|
|
213
|
+
function isNotFoundText(text) {
|
|
214
|
+
return /"status"\s*:\s*404\b|\bproject not found\b|Olympus API returned 404\b/i.test(String(text || ''));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* One lookup call that answers null for "no such row" instead of throwing.
|
|
219
|
+
* The MCP proxy flags an upstream 404 with isError, which callTool turns into
|
|
220
|
+
* a thrown Error carrying the status; older servers handed the error body
|
|
221
|
+
* back as a completed call with no `data`. Both read as null here. Anything
|
|
222
|
+
* else (network, auth, 500) still throws.
|
|
223
|
+
*/
|
|
224
|
+
async function lookupRow(client, toolName, args) {
|
|
225
|
+
let body;
|
|
226
|
+
try {
|
|
227
|
+
body = await client.callToolJson(toolName, args);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (isNotFoundText(err?.message)) return null;
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
const row = body && typeof body === 'object' && body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
233
|
+
? body.data
|
|
234
|
+
: null;
|
|
235
|
+
if (row && row.id) return row;
|
|
236
|
+
if (body && typeof body === 'object' && body.error !== undefined) {
|
|
237
|
+
if (body.status === 404 || isNotFoundText(body.error)) return null;
|
|
238
|
+
throw new Error(`Tool ${toolName} failed: ${body.error}`);
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Every row of a paginated list tool (sites_list, list_projects), walking `pagination.total_pages`. */
|
|
244
|
+
async function listAllRows(client, toolName) {
|
|
245
|
+
const rows = [];
|
|
246
|
+
let page = 1;
|
|
247
|
+
while (true) {
|
|
248
|
+
const result = await client.callToolJson(toolName, { page, limit: LIST_PAGE_LIMIT });
|
|
249
|
+
const batch = Array.isArray(result?.data) ? result.data : [];
|
|
250
|
+
rows.push(...batch);
|
|
251
|
+
const totalPages = result?.pagination?.total_pages ?? 1;
|
|
252
|
+
if (page >= totalPages || batch.length === 0) break;
|
|
253
|
+
page += 1;
|
|
146
254
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The website project a PM row points at, if any. */
|
|
259
|
+
function linkedWebsiteId(pmRow) {
|
|
260
|
+
if (!pmRow) return null;
|
|
261
|
+
return pmRow.website_project_id || pmRow.linked_site?.id || null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const PROJECTS_HINT = 'Run `hiveku-sync projects` to list the website projects on this account.';
|
|
265
|
+
|
|
266
|
+
async function resolveProjectByUuid(client, projectId) {
|
|
267
|
+
const website = await lookupRow(client, 'project_get', { project_id: projectId });
|
|
268
|
+
if (website) return website;
|
|
269
|
+
|
|
270
|
+
// Not a website id. It may be the PM project that tracks one.
|
|
271
|
+
const pmRow = await lookupRow(client, 'get_project', { project_id: projectId });
|
|
272
|
+
const linkedId = linkedWebsiteId(pmRow);
|
|
273
|
+
if (linkedId) {
|
|
274
|
+
const linked = await lookupRow(client, 'project_get', { project_id: linkedId });
|
|
275
|
+
if (linked) return linked;
|
|
276
|
+
throw new Error(
|
|
277
|
+
`${projectId} is the PM project "${pmRow.name || projectId}" (pm_projects), which links website project ${linkedId}, ` +
|
|
278
|
+
`but project_get finds no website project with that id. ${PROJECTS_HINT}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (pmRow) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`${projectId} is the PM project "${pmRow.name || projectId}" (pm_projects, the id space of get_project / list_projects) ` +
|
|
284
|
+
`and it links no website project. hiveku-sync pulls code from website projects ` +
|
|
285
|
+
`(website_projects, the id space of project_get / sites_list). ${PROJECTS_HINT}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
throw new Error(
|
|
289
|
+
`No project with id ${projectId}: it is neither a website project (website_projects, the id space of ` +
|
|
290
|
+
`project_get / sites_list) nor a PM project (pm_projects, the id space of get_project / list_projects). ${PROJECTS_HINT}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Match a slug against website rows: exact subdomain, then exact name
|
|
296
|
+
* (case-insensitive), then the name slugified the way pull-project names the
|
|
297
|
+
* local folder, so the folder name under ./projects/ resolves too.
|
|
298
|
+
*/
|
|
299
|
+
function matchSiteRow(rows, slug) {
|
|
300
|
+
const lower = slug.toLowerCase();
|
|
301
|
+
const slugified = safeSlug(slug);
|
|
302
|
+
return (
|
|
303
|
+
rows.find((row) => String(row.subdomain || '').toLowerCase() === lower) ||
|
|
304
|
+
rows.find((row) => String(row.name || '').toLowerCase() === lower) ||
|
|
305
|
+
rows.find((row) => safeSlug(row.name) === slugified) ||
|
|
306
|
+
null
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function matchPmRow(rows, slug) {
|
|
311
|
+
const lower = slug.toLowerCase();
|
|
312
|
+
return (
|
|
313
|
+
rows.find((row) => String(row.slug || '').toLowerCase() === lower) ||
|
|
314
|
+
rows.find((row) => String(row.name || '').toLowerCase() === lower) ||
|
|
315
|
+
null
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function resolveProjectBySlug(client, slug) {
|
|
320
|
+
const sites = await listAllRows(client, 'sites_list');
|
|
321
|
+
const site = matchSiteRow(sites, slug);
|
|
322
|
+
if (site) return site;
|
|
323
|
+
|
|
324
|
+
// Fall back to the PM project of that slug/name and follow its link.
|
|
325
|
+
const pmRows = await listAllRows(client, 'list_projects');
|
|
326
|
+
const pmRow = matchPmRow(pmRows, slug);
|
|
327
|
+
const linkedId = linkedWebsiteId(pmRow);
|
|
328
|
+
if (linkedId) {
|
|
329
|
+
const linked = await lookupRow(client, 'project_get', { project_id: linkedId });
|
|
330
|
+
if (linked) return linked;
|
|
331
|
+
throw new Error(
|
|
332
|
+
`"${slug}" is the PM project "${pmRow.name || slug}" (pm_projects), which links website project ${linkedId}, ` +
|
|
333
|
+
`but project_get finds no website project with that id. ${PROJECTS_HINT}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (pmRow) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`"${slug}" is the PM project "${pmRow.name || slug}" (pm_projects) and it links no website project. ` +
|
|
339
|
+
`hiveku-sync pulls code from website projects (website_projects, sites_list). ${PROJECTS_HINT}`,
|
|
340
|
+
);
|
|
155
341
|
}
|
|
156
|
-
|
|
342
|
+
throw new Error(
|
|
343
|
+
`No website project matching "${slug}": nothing in sites_list has that subdomain or name, and no PM project ` +
|
|
344
|
+
`(list_projects) with that slug or name links a website project. ${PROJECTS_HINT}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Resolve a website project (website_projects row) from a UUID or a slug.
|
|
350
|
+
* A UUID is tried as a website id (project_get) and then as a PM id
|
|
351
|
+
* (get_project → website_project_id → project_get). A slug is matched against
|
|
352
|
+
* sites_list (subdomain, then name) and then against list_projects
|
|
353
|
+
* (slug, then name) → its linked website project. The row returned always
|
|
354
|
+
* carries project_type and external_platform for refuseExternal.
|
|
355
|
+
*/
|
|
356
|
+
export async function resolveProject(client, slugOrId) {
|
|
357
|
+
if (UUID_RE.test(slugOrId)) return resolveProjectByUuid(client, slugOrId);
|
|
358
|
+
return resolveProjectBySlug(client, slugOrId);
|
|
157
359
|
}
|
|
158
360
|
|
|
159
|
-
export async function runProjectsList({ profile } = {}) {
|
|
160
|
-
const { client } = await
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
console.log('No projects on this account.');
|
|
361
|
+
export async function runProjectsList({ profile, client: injectedClient } = {}) {
|
|
362
|
+
const { client } = await resolveClient({ client: injectedClient, profile });
|
|
363
|
+
const sites = await listAllRows(client, 'sites_list');
|
|
364
|
+
if (sites.length === 0) {
|
|
365
|
+
console.log('No website projects on this account.');
|
|
165
366
|
return;
|
|
166
367
|
}
|
|
167
|
-
console.log(`${
|
|
168
|
-
for (const
|
|
169
|
-
const type =
|
|
170
|
-
const
|
|
171
|
-
|
|
368
|
+
console.log(`${sites.length} website project${sites.length !== 1 ? 's' : ''}:\n`);
|
|
369
|
+
for (const site of sites) {
|
|
370
|
+
const type = site.project_type || '?';
|
|
371
|
+
const handle = site.subdomain || site.name || site.id;
|
|
372
|
+
const nameColumn = site.name && site.name !== handle ? ` ${site.name}` : '';
|
|
373
|
+
console.log(` ${handle.padEnd(32)} ${type.padEnd(14)} ${site.id}${nameColumn}`);
|
|
172
374
|
}
|
|
173
375
|
}
|
|
174
376
|
|
|
175
|
-
export async function runPullProject({ slugOrId, profile, branch }) {
|
|
377
|
+
export async function runPullProject({ slugOrId, profile, branch, client: injectedClient, baseDir: baseDirOverride }) {
|
|
176
378
|
if (!slugOrId) throw new Error('Provide a project slug or UUID');
|
|
177
|
-
const { client, baseDir } = await
|
|
379
|
+
const { client, baseDir } = await resolveClient({ client: injectedClient, profile, baseDir: baseDirOverride });
|
|
178
380
|
const project = await resolveProject(client, slugOrId);
|
|
381
|
+
// An external site has no file tree: the pull would write nothing and stamp
|
|
382
|
+
// a RUN.md that says "unknown project type". Refuse before the first file call.
|
|
383
|
+
refuseExternal(project);
|
|
179
384
|
const projectId = project.id;
|
|
180
385
|
const localSlug = safeSlug(project.slug || project.name || projectId);
|
|
181
386
|
const dest = join(baseDir, PROJECTS_DIR, localSlug);
|
|
@@ -259,6 +464,7 @@ export async function runPullProject({ slugOrId, profile, branch }) {
|
|
|
259
464
|
project_id: projectId,
|
|
260
465
|
name: project.name || project.slug || localSlug,
|
|
261
466
|
project_type: project.project_type || null,
|
|
467
|
+
external_platform: project.external_platform || null,
|
|
262
468
|
branch: branch || null,
|
|
263
469
|
last_pull_at: new Date().toISOString(),
|
|
264
470
|
};
|
|
@@ -301,16 +507,17 @@ function summarizeDiff(diff) {
|
|
|
301
507
|
};
|
|
302
508
|
}
|
|
303
509
|
|
|
304
|
-
export async function runProjectStatus({ slug, profile, json = false }) {
|
|
510
|
+
export async function runProjectStatus({ slug, profile, json = false, client: injectedClient, baseDir: baseDirOverride }) {
|
|
305
511
|
// Resolve baseDir as the account folder. If we're inside ./projects/<x>/,
|
|
306
512
|
// walk up two levels. Otherwise treat cwd as the account folder.
|
|
307
513
|
const cwd = process.cwd();
|
|
308
514
|
const projectsIdx = cwd.indexOf(`${sep}${PROJECTS_DIR}${sep}`);
|
|
309
|
-
const
|
|
515
|
+
const detectedBaseDir =
|
|
310
516
|
projectsIdx > -1 ? cwd.slice(0, projectsIdx) : cwd;
|
|
311
517
|
|
|
312
|
-
const { client } = await
|
|
518
|
+
const { client, baseDir } = await resolveClient({ client: injectedClient, profile, baseDir: baseDirOverride || detectedBaseDir });
|
|
313
519
|
const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
|
|
520
|
+
refuseExternal(info);
|
|
314
521
|
|
|
315
522
|
if (!existsSync(folder)) {
|
|
316
523
|
throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
|
|
@@ -387,6 +594,8 @@ export async function runProjectPush({
|
|
|
387
594
|
profile,
|
|
388
595
|
includeDeletes = false,
|
|
389
596
|
dryRun = false,
|
|
597
|
+
client: injectedClient,
|
|
598
|
+
baseDir: baseDirOverride,
|
|
390
599
|
}) {
|
|
391
600
|
if (!message || !message.trim()) {
|
|
392
601
|
throw new Error('Provide a commit message via -m "..."');
|
|
@@ -394,10 +603,11 @@ export async function runProjectPush({
|
|
|
394
603
|
|
|
395
604
|
const cwd = process.cwd();
|
|
396
605
|
const projectsIdx = cwd.indexOf(`${sep}${PROJECTS_DIR}${sep}`);
|
|
397
|
-
const
|
|
606
|
+
const detectedBaseDir = projectsIdx > -1 ? cwd.slice(0, projectsIdx) : cwd;
|
|
398
607
|
|
|
399
|
-
const { client } = await
|
|
608
|
+
const { client, baseDir } = await resolveClient({ client: injectedClient, profile, baseDir: baseDirOverride || detectedBaseDir });
|
|
400
609
|
const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
|
|
610
|
+
refuseExternal(info);
|
|
401
611
|
|
|
402
612
|
if (!existsSync(folder)) {
|
|
403
613
|
throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
|
package/src/pull.mjs
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* `hiveku-sync pull` — fetches memory/rules/skills via the MCP memory_list tool
|
|
3
3
|
* and writes them as local .md files under the current folder.
|
|
4
4
|
*
|
|
5
|
+
* The account memory (domains `account` and `account-suggestions`) is not a
|
|
6
|
+
* knowledge entry: it is classified explicitly, never written under memory/,
|
|
7
|
+
* and pulled once from account_memory_get into a read-only
|
|
8
|
+
* hiveku-data/account/ACCOUNT_MEMORY.md (see account-memory.mjs).
|
|
9
|
+
*
|
|
5
10
|
* Safety: refuses to overwrite when the .env API key no longer matches the
|
|
6
11
|
* manifest's recorded key preview (prevents pulling Account A's data into a
|
|
7
12
|
* folder previously synced for Account B — the 2026-05-11 cross-tenant bug
|
|
@@ -22,6 +27,15 @@ import {
|
|
|
22
27
|
TYPE_TO_FOLDER,
|
|
23
28
|
SUPPORTED_TYPES,
|
|
24
29
|
} from './util.mjs';
|
|
30
|
+
import {
|
|
31
|
+
ACCOUNT_MEMORY_FILE,
|
|
32
|
+
classifyMemoryEntry,
|
|
33
|
+
ACCOUNT_MEMORY_TOOL,
|
|
34
|
+
normalizeAccountMemory,
|
|
35
|
+
renderAccountMemoryFile,
|
|
36
|
+
resolveAccountId,
|
|
37
|
+
writeReadOnly,
|
|
38
|
+
} from './account-memory.mjs';
|
|
25
39
|
|
|
26
40
|
const MANIFEST_PATH = '.hiveku/manifest.json';
|
|
27
41
|
|
|
@@ -58,7 +72,19 @@ function renderEntry(entry, type) {
|
|
|
58
72
|
return `${frontmatter}${body}`;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Write hiveku-data/account/ACCOUNT_MEMORY.md from account_memory_get, read-only.
|
|
77
|
+
* Returns { file, version, suggestions, accountId }, or throws (the caller
|
|
78
|
+
* records the error and keeps the previous copy).
|
|
79
|
+
*/
|
|
80
|
+
export async function pullAccountMemory({ client, baseDir, fetchedAt = new Date().toISOString() }) {
|
|
81
|
+
const memory = normalizeAccountMemory(await client.callToolJson(ACCOUNT_MEMORY_TOOL, {}));
|
|
82
|
+
const accountId = await resolveAccountId(client);
|
|
83
|
+
await writeReadOnly(join(baseDir, ACCOUNT_MEMORY_FILE), renderAccountMemoryFile({ memory, accountId, fetchedAt }));
|
|
84
|
+
return { file: ACCOUNT_MEMORY_FILE, version: memory.version, suggestions: memory.suggestions.length, accountId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function runPull({ baseDir = process.cwd(), profile, client: injectedClient } = {}) {
|
|
62
88
|
const envPath = join(baseDir, '.env');
|
|
63
89
|
const dotenv = await loadDotenv(envPath);
|
|
64
90
|
const apiKey = readEnv(process.env, dotenv, 'OLYMPUS_API_KEY');
|
|
@@ -88,10 +114,10 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
|
|
|
88
114
|
);
|
|
89
115
|
}
|
|
90
116
|
|
|
91
|
-
const client = new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
|
|
92
|
-
await client.initialize();
|
|
117
|
+
const client = injectedClient || new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
|
|
118
|
+
if (!injectedClient) await client.initialize();
|
|
93
119
|
|
|
94
|
-
const summary = { total: 0, by_type: {}, errors: [] };
|
|
120
|
+
const summary = { total: 0, by_type: {}, errors: [], account_memory: null, account_rows_skipped: 0 };
|
|
95
121
|
const writtenFiles = new Set();
|
|
96
122
|
|
|
97
123
|
for (const type of SUPPORTED_TYPES) {
|
|
@@ -103,10 +129,19 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
|
|
|
103
129
|
summary.errors.push({ type, message: err.message });
|
|
104
130
|
continue;
|
|
105
131
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
// The two account-memory domains are not knowledge entries: an older
|
|
133
|
+
// server still lists them, and they would land as memory/account.md and
|
|
134
|
+
// memory/account-suggestions.md (raw JSON Lines) looking editable. They
|
|
135
|
+
// are written once, read-only, from account_memory_get below.
|
|
136
|
+
const knowledge = [];
|
|
109
137
|
for (const entry of entries) {
|
|
138
|
+
if (classifyMemoryEntry(entry, folder).kind === 'knowledge') knowledge.push(entry);
|
|
139
|
+
else summary.account_rows_skipped += 1;
|
|
140
|
+
}
|
|
141
|
+
summary.by_type[type] = knowledge.length;
|
|
142
|
+
summary.total += knowledge.length;
|
|
143
|
+
|
|
144
|
+
for (const entry of knowledge) {
|
|
110
145
|
const filename = entryFilename(entry);
|
|
111
146
|
const fullPath = join(baseDir, folder, filename);
|
|
112
147
|
await writeFileAtomic(fullPath, renderEntry(entry, type));
|
|
@@ -114,6 +149,17 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
|
|
|
114
149
|
}
|
|
115
150
|
}
|
|
116
151
|
|
|
152
|
+
// The account memory: one read-only file, never uploaded by anything here.
|
|
153
|
+
// A failure keeps the previous copy (and its manifest line), so it is not
|
|
154
|
+
// reported as removed upstream.
|
|
155
|
+
try {
|
|
156
|
+
summary.account_memory = await pullAccountMemory({ client, baseDir });
|
|
157
|
+
writtenFiles.add(ACCOUNT_MEMORY_FILE);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
summary.errors.push({ type: 'account memory', message: err.message });
|
|
160
|
+
if ((manifest?.files ?? []).includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
|
|
161
|
+
}
|
|
162
|
+
|
|
117
163
|
// v1: don't delete stale files; just record them in the manifest so the
|
|
118
164
|
// user can prune manually if they want. Avoids losing local edits.
|
|
119
165
|
const previouslySeen = new Set(manifest?.files ?? []);
|
|
@@ -129,6 +175,8 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
|
|
|
129
175
|
last_sync_at: new Date().toISOString(),
|
|
130
176
|
counts: summary.by_type,
|
|
131
177
|
files: [...writtenFiles].sort(),
|
|
178
|
+
// Pulled for reading only: edited on the dashboard, never uploaded.
|
|
179
|
+
read_only: [ACCOUNT_MEMORY_FILE],
|
|
132
180
|
removed_remotely_since_last_sync: removedRemotely,
|
|
133
181
|
},
|
|
134
182
|
null,
|
package/src/push.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { join, relative, sep } from 'node:path';
|
|
|
28
28
|
import {
|
|
29
29
|
clientFromCwd,
|
|
30
30
|
findProjectFolder,
|
|
31
|
+
refuseExternal,
|
|
31
32
|
resolveProject,
|
|
32
33
|
walkLocalFiles,
|
|
33
34
|
readProjectsManifest,
|
|
@@ -225,10 +226,13 @@ export async function computePushPlan({ client, projectId, folder, includeDelete
|
|
|
225
226
|
|
|
226
227
|
// Thin CLI wrapper: resolve the MCP client + project folder from cwd, then run
|
|
227
228
|
// the (fully testable) orchestration in pushProject.
|
|
228
|
-
export async function runPush({ slug, profile, ...opts } = {}) {
|
|
229
|
-
const baseDir = resolveBaseDir();
|
|
230
|
-
const
|
|
229
|
+
export async function runPush({ slug, profile, client: injectedClient, baseDir: baseDirOverride, ...opts } = {}) {
|
|
230
|
+
const baseDir = baseDirOverride || resolveBaseDir();
|
|
231
|
+
const client = injectedClient || (await clientFromCwd({ profileOverride: profile })).client;
|
|
231
232
|
const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
|
|
233
|
+
// An external site (Webflow, WordPress, any tracked host) has no deploy
|
|
234
|
+
// source to push to; refuse before the diff.
|
|
235
|
+
refuseExternal(info);
|
|
232
236
|
if (!existsSync(folder)) {
|
|
233
237
|
throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
|
|
234
238
|
}
|
|
@@ -500,22 +504,32 @@ async function runDeployFor({ client, projectId, slug, environment, wait }) {
|
|
|
500
504
|
return { deployment_id: deploymentId, environment: env, status: last, waited: true, ok: false };
|
|
501
505
|
}
|
|
502
506
|
|
|
503
|
-
export async function runDeploy({ slugOrId, environment, wait = false, profile }) {
|
|
507
|
+
export async function runDeploy({ slugOrId, environment, wait = false, profile, client: injectedClient, baseDir: baseDirOverride }) {
|
|
504
508
|
if (!environment) throw new Error('Usage: hiveku-sync deploy <development|staging|production> [<slug>] [--wait]');
|
|
505
|
-
const baseDir = resolveBaseDir();
|
|
506
|
-
const
|
|
509
|
+
const baseDir = baseDirOverride || resolveBaseDir();
|
|
510
|
+
const client = injectedClient || (await clientFromCwd({ profileOverride: profile })).client;
|
|
507
511
|
|
|
508
512
|
// Resolve project: prefer the pulled-folder manifest (slug), else treat the
|
|
509
|
-
// arg as a slug/UUID via
|
|
513
|
+
// arg as a slug/UUID via resolveProject (project_get / sites_list, the
|
|
514
|
+
// website_projects id space - not the PM tools). Either way an external site has
|
|
515
|
+
// nothing to deploy (deploy_site 400s on project_type external) - refuse
|
|
516
|
+
// with the pointer to where it IS managed.
|
|
510
517
|
let projectId;
|
|
511
518
|
let slug = slugOrId;
|
|
519
|
+
let found;
|
|
512
520
|
try {
|
|
513
|
-
|
|
514
|
-
projectId = info.project_id;
|
|
515
|
-
slug = s;
|
|
521
|
+
found = await findProjectFolder({ slug: slugOrId, baseDir });
|
|
516
522
|
} catch {
|
|
523
|
+
found = null;
|
|
524
|
+
}
|
|
525
|
+
if (found) {
|
|
526
|
+
refuseExternal(found.info);
|
|
527
|
+
projectId = found.info.project_id;
|
|
528
|
+
slug = found.slug;
|
|
529
|
+
} else {
|
|
517
530
|
if (!slugOrId) throw new Error('No project folder detected — pass a project slug or UUID: hiveku-sync deploy <env> <slug>');
|
|
518
531
|
const project = await resolveProject(client, slugOrId);
|
|
532
|
+
refuseExternal(project);
|
|
519
533
|
projectId = project.id;
|
|
520
534
|
slug = project.slug || project.name || project.id;
|
|
521
535
|
}
|
|
@@ -587,8 +601,12 @@ export async function runPullEnv({ slug, profile, target = 'development' } = {})
|
|
|
587
601
|
|
|
588
602
|
const res = (await client.callToolJson('project_secrets_list', { project_id: projectId })).data;
|
|
589
603
|
const secrets = res?.secrets || {};
|
|
604
|
+
// Variables marked sensitive are write-only on the platform: the server OMITS them
|
|
605
|
+
// from `secrets` and lists their names here. Without saying so, the .env file below
|
|
606
|
+
// would be silently incomplete and the user would go debugging a phantom.
|
|
607
|
+
const sensitiveKeys = Array.isArray(res?.sensitive_keys) ? res.sensitive_keys : [];
|
|
590
608
|
const count = Object.keys(secrets).length;
|
|
591
|
-
if (count === 0) {
|
|
609
|
+
if (count === 0 && sensitiveKeys.length === 0) {
|
|
592
610
|
console.log(`No secrets set on ${resolvedSlug}. Nothing to write.`);
|
|
593
611
|
return { written: 0 };
|
|
594
612
|
}
|
|
@@ -614,5 +632,12 @@ export async function runPullEnv({ slug, profile, target = 'development' } = {})
|
|
|
614
632
|
}
|
|
615
633
|
|
|
616
634
|
console.log(`Wrote ${Object.keys(resolved).length} var${Object.keys(resolved).length !== 1 ? 's' : ''} → ${outName} (tier: ${tier}, from ${count} secret${count !== 1 ? 's' : ''}).`);
|
|
617
|
-
|
|
635
|
+
if (sensitiveKeys.length > 0) {
|
|
636
|
+
const names = sensitiveKeys.slice().sort();
|
|
637
|
+
console.log(
|
|
638
|
+
`Skipped ${names.length} sensitive var${names.length !== 1 ? 's' : ''}: ${names.join(', ')}.`,
|
|
639
|
+
);
|
|
640
|
+
console.log('These are write-only on Hiveku and cannot be read back. Set them by hand if your local build needs them.');
|
|
641
|
+
}
|
|
642
|
+
return { written: Object.keys(resolved).length, skippedSensitive: sensitiveKeys, path: outPath, tier };
|
|
618
643
|
}
|