@hiveku-apps/sync 0.1.0 → 0.2.0
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 +51 -4
- package/bin/hiveku-sync.mjs +112 -22
- package/package.json +4 -3
- package/src/projects.mjs +77 -42
- package/src/push.mjs +618 -0
package/README.md
CHANGED
|
@@ -78,6 +78,53 @@ narrowed tool list. Available: `full`, `sales`, `marketing`, `helpdesk`,
|
|
|
78
78
|
`pm`, `dev`. Change `HIVEKU_PROFILE` in `.env` and reload the MCP server
|
|
79
79
|
in your editor to switch.
|
|
80
80
|
|
|
81
|
+
## Coder projects — pull, ship, deploy
|
|
82
|
+
|
|
83
|
+
Beyond memory/rules/skills, `hiveku-sync` can drive a Hiveku **website project**
|
|
84
|
+
end-to-end from the terminal — the same push/deploy loop the VS Code extension's
|
|
85
|
+
Source Control gives you, but headless (Claude Code in a terminal, CI, a cron).
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
hiveku-sync projects # list the account's coder projects
|
|
89
|
+
hiveku-sync pull-project <slug-or-id> # → ./projects/<slug>/ (+ .hiveku/projects.json)
|
|
90
|
+
|
|
91
|
+
cd projects/<slug>
|
|
92
|
+
hiveku-sync pull-env # secrets → .env.local (git-ignored)
|
|
93
|
+
# ...edit files, run the dev server...
|
|
94
|
+
hiveku-sync status # diff local vs the deploy source
|
|
95
|
+
hiveku-sync push # save changes to the deploy source
|
|
96
|
+
hiveku-sync push --deploy development --wait # ...then deploy and wait
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**`push` writes the deploy source, lane-routed.** This is the important part.
|
|
100
|
+
`deploy_site` builds from `builder_code_versions` (+ the S3/CDN asset store) —
|
|
101
|
+
**not** a GitHub branch. So `push`:
|
|
102
|
+
|
|
103
|
+
- routes **CDN-servable binary** (images/fonts/video under `public/<subdir>/`)
|
|
104
|
+
to `assets_upload` (S3 → CloudFront). A binary put in `builder_code_versions`
|
|
105
|
+
renders in the Fly preview but **vanishes on deploy** (the build excludes it) —
|
|
106
|
+
`push` never makes that mistake.
|
|
107
|
+
- routes everything else (code, config, text-under-public) to
|
|
108
|
+
`project_files_bulk_save` in small, per-batch-verified batches (transport-safe
|
|
109
|
+
vs one giant call).
|
|
110
|
+
- diffs correctly: code via a single `project_files_status` call (so
|
|
111
|
+
`only_remote` stays reliable — chunking it produces phantom deletions), assets
|
|
112
|
+
via `assets_list` (so unchanged images aren't re-uploaded every push).
|
|
113
|
+
|
|
114
|
+
Flags: `--include-deletes` (remove code paths missing locally), `--no-assets` /
|
|
115
|
+
`--assets-all`, `--commit "msg"` (also record a Hiveku-native `main` commit),
|
|
116
|
+
`--deploy <env> [--wait]`, `--dry-run`.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
hiveku-sync deploy <development|staging|production> [<slug>] [--wait]
|
|
120
|
+
hiveku-sync pull-env [<slug>] [--target development|staging|production]
|
|
121
|
+
hiveku-sync commit -m "msg" # legacy GitHub-branch commit (GitHub-connected projects only)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`pull-env` applies the same `_DEV` / `_PROD` / `_STAGING` suffix resolution the
|
|
125
|
+
preview machine uses: for `development` it strips `_DEV` to the base key and
|
|
126
|
+
skips `_PROD`/`_STAGING`; `--target production` does the inverse.
|
|
127
|
+
|
|
81
128
|
## Safety
|
|
82
129
|
|
|
83
130
|
- **Manifest pin** — `.hiveku/manifest.json` records the API key preview.
|
|
@@ -91,7 +138,7 @@ in your editor to switch.
|
|
|
91
138
|
|
|
92
139
|
## How writes work
|
|
93
140
|
|
|
94
|
-
`hiveku-sync`
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
refresh the local copies.
|
|
141
|
+
For **projects**, `hiveku-sync push` / `deploy` write straight back to Hiveku
|
|
142
|
+
(see "Coder projects" above). For **memory/rules/skills**, `pull` is read-only —
|
|
143
|
+
persist changes with the `memory_create` / `memory_update` / `memory_delete` MCP
|
|
144
|
+
tools through Claude, then `hiveku-sync pull` to refresh the local copies.
|
package/bin/hiveku-sync.mjs
CHANGED
|
@@ -16,20 +16,39 @@ import {
|
|
|
16
16
|
runProjectStatus,
|
|
17
17
|
runProjectPush,
|
|
18
18
|
} from '../src/projects.mjs';
|
|
19
|
+
import { runPush, runDeploy, runPullEnv } from '../src/push.mjs';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
19
21
|
|
|
20
|
-
function parseFlags(argv) {
|
|
22
|
+
export function parseFlags(argv) {
|
|
21
23
|
const positional = [];
|
|
22
24
|
const flags = {};
|
|
23
|
-
// Short flags that take
|
|
25
|
+
// Short flags that take the NEXT arg as their value.
|
|
24
26
|
const VALUE_SHORT = new Set(['m']);
|
|
27
|
+
// Long flags that take the next arg as their value when not given with `=`.
|
|
28
|
+
const VALUE_LONG = new Set([
|
|
29
|
+
'message',
|
|
30
|
+
'commit',
|
|
31
|
+
'deploy',
|
|
32
|
+
'target',
|
|
33
|
+
'branch',
|
|
34
|
+
'profile',
|
|
35
|
+
'parent',
|
|
36
|
+
]);
|
|
25
37
|
for (let i = 0; i < argv.length; i++) {
|
|
26
38
|
const arg = argv[i];
|
|
27
39
|
if (arg.startsWith('--')) {
|
|
28
40
|
const eq = arg.indexOf('=');
|
|
29
|
-
if (eq
|
|
30
|
-
flags[arg.slice(2)] = true;
|
|
31
|
-
} else {
|
|
41
|
+
if (eq !== -1) {
|
|
32
42
|
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
43
|
+
} else {
|
|
44
|
+
const key = arg.slice(2);
|
|
45
|
+
const nextIsValue = i + 1 < argv.length && !argv[i + 1].startsWith('-');
|
|
46
|
+
if (VALUE_LONG.has(key) && nextIsValue) {
|
|
47
|
+
flags[key] = argv[i + 1];
|
|
48
|
+
i += 1;
|
|
49
|
+
} else {
|
|
50
|
+
flags[key] = true;
|
|
51
|
+
}
|
|
33
52
|
}
|
|
34
53
|
} else if (arg.startsWith('-') && arg.length > 1) {
|
|
35
54
|
const key = arg.slice(1);
|
|
@@ -59,18 +78,44 @@ Coder projects:
|
|
|
59
78
|
projects List the account's coder projects.
|
|
60
79
|
pull-project <slug-or-id> Pull a project's source into ./projects/<slug>/.
|
|
61
80
|
[--branch=<name>] Records the project mapping in .hiveku/projects.json.
|
|
62
|
-
status [<slug>] [--json] Diff local ./projects/<slug>/ vs
|
|
63
|
-
cd
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
status [<slug>] [--json] Diff local ./projects/<slug>/ vs the deploy source
|
|
82
|
+
(builder_code_versions). cd in to omit <slug>.
|
|
83
|
+
|
|
84
|
+
Ship a project (the agent-native deploy loop):
|
|
85
|
+
push [<slug>] Save local changes to the DEPLOY SOURCE, lane-routed:
|
|
86
|
+
[--include-deletes] images/fonts/video under public/<subdir>/ → the
|
|
87
|
+
[--no-assets] [--assets-all] S3/CDN asset store; everything else → batched,
|
|
88
|
+
[--commit "<msg>"] verified bulk_save. This is what deploy reads.
|
|
89
|
+
[--deploy <env>] [--wait] --commit also records a Hiveku 'main' commit.
|
|
90
|
+
[--dry-run] [--branch=<name>] --deploy <env> [--wait] deploys after the push.
|
|
91
|
+
deploy <env> [<slug>] [--wait] Deploy to development | staging | production and
|
|
92
|
+
(with --wait) poll to a terminal status.
|
|
93
|
+
pull-env [<slug>] Write project secrets → .env.local (git-ignored),
|
|
94
|
+
[--target <env>] with the same _DEV/_PROD/_STAGING resolution the
|
|
95
|
+
preview uses. --target staging|production for the
|
|
96
|
+
other tiers.
|
|
97
|
+
commit [<slug>] -m "<msg>" GitHub-branch commit via project_commit (only for
|
|
98
|
+
[--branch=<name>] GitHub-connected projects; most sites use push).
|
|
99
|
+
[--include-deletes]
|
|
68
100
|
|
|
69
101
|
help Show this message.
|
|
70
102
|
|
|
71
103
|
Profiles: full, sales, marketing, helpdesk, pm, dev
|
|
72
104
|
`;
|
|
73
105
|
|
|
106
|
+
function commitMessageFrom(flags) {
|
|
107
|
+
// --commit "msg" | --commit=msg | -m "msg" | --message "msg". A bare --commit
|
|
108
|
+
// (boolean true) means "commit with no message" → fall back to the default.
|
|
109
|
+
const c = typeof flags['commit'] === 'string' ? flags['commit'] : undefined;
|
|
110
|
+
const m = typeof flags['m'] === 'string' ? flags['m'] : undefined;
|
|
111
|
+
const msg = typeof flags['message'] === 'string' ? flags['message'] : undefined;
|
|
112
|
+
if (c) return c;
|
|
113
|
+
if (m) return m;
|
|
114
|
+
if (msg) return msg;
|
|
115
|
+
if (flags['commit'] === true) return 'hiveku-sync: push';
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
74
119
|
async function main() {
|
|
75
120
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
76
121
|
const { positional, flags } = parseFlags(rest);
|
|
@@ -144,16 +189,55 @@ async function main() {
|
|
|
144
189
|
break;
|
|
145
190
|
}
|
|
146
191
|
case 'push': {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
192
|
+
const deployEnv = typeof flags['deploy'] === 'string' ? flags['deploy'] : undefined;
|
|
193
|
+
await runPush({
|
|
194
|
+
slug: positional[0],
|
|
195
|
+
profile: flags['profile'],
|
|
196
|
+
commit: commitMessageFrom(flags),
|
|
197
|
+
deploy: deployEnv,
|
|
198
|
+
wait: !!flags['wait'],
|
|
199
|
+
includeDeletes: !!flags['include-deletes'],
|
|
200
|
+
noAssets: !!flags['no-assets'],
|
|
201
|
+
assetsAll: !!flags['assets-all'],
|
|
202
|
+
dryRun: !!flags['dry-run'],
|
|
203
|
+
branch: flags['branch'],
|
|
204
|
+
});
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case 'deploy': {
|
|
208
|
+
// `deploy <env> [<slug>]` — env is the first positional.
|
|
209
|
+
const environment = positional[0];
|
|
210
|
+
if (!environment) {
|
|
211
|
+
console.error('Error: deploy requires an environment: development | staging | production\n');
|
|
212
|
+
console.error(HELP);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
await runDeploy({
|
|
216
|
+
environment,
|
|
217
|
+
slugOrId: positional[1],
|
|
218
|
+
wait: !!flags['wait'],
|
|
219
|
+
profile: flags['profile'],
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'pull-env': {
|
|
224
|
+
await runPullEnv({
|
|
225
|
+
slug: positional[0],
|
|
226
|
+
profile: flags['profile'],
|
|
227
|
+
target: typeof flags['target'] === 'string' ? flags['target'] : 'development',
|
|
228
|
+
});
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'commit': {
|
|
232
|
+
// The legacy GitHub-branch commit path (project_commit). Requires a repo.
|
|
233
|
+
const message = commitMessageFrom(flags);
|
|
234
|
+
if (!message || message === 'hiveku-sync: push') {
|
|
235
|
+
console.error('Error: commit requires -m "<commit message>"\n');
|
|
152
236
|
console.error(HELP);
|
|
153
237
|
process.exit(1);
|
|
154
238
|
}
|
|
155
239
|
await runProjectPush({
|
|
156
|
-
slug,
|
|
240
|
+
slug: positional[0],
|
|
157
241
|
message,
|
|
158
242
|
branch: flags['branch'],
|
|
159
243
|
profile: flags['profile'],
|
|
@@ -175,8 +259,14 @@ async function main() {
|
|
|
175
259
|
}
|
|
176
260
|
}
|
|
177
261
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
process.
|
|
182
|
-
|
|
262
|
+
// Only run the CLI when invoked directly — importing this module (e.g. from
|
|
263
|
+
// tests to reach parseFlags) must not execute main().
|
|
264
|
+
const isEntrypoint =
|
|
265
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
266
|
+
if (isEntrypoint) {
|
|
267
|
+
main().catch((err) => {
|
|
268
|
+
console.error(`hiveku-sync: ${err.message}`);
|
|
269
|
+
if (process.env.HIVEKU_SYNC_DEBUG) console.error(err.stack);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hiveku-apps/sync",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Sync a Hiveku account's memory/rules/skills + coder projects into a local folder. Pairs with the Hiveku MCP at core.hiveku.com/mcp so Claude Code can drive Hiveku tools per-folder.",
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"author": "Hiveku",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"start": "node bin/hiveku-sync.mjs"
|
|
39
|
+
"start": "node bin/hiveku-sync.mjs",
|
|
40
|
+
"test": "node --test 'test/*.test.mjs'"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/src/projects.mjs
CHANGED
|
@@ -19,8 +19,11 @@ import {
|
|
|
19
19
|
writeFileAtomic,
|
|
20
20
|
readJson,
|
|
21
21
|
} from './util.mjs';
|
|
22
|
+
// NOTE: push.mjs imports helpers from this module; the cycle is safe because
|
|
23
|
+
// both sides reference the imports only inside functions (never at module eval).
|
|
24
|
+
import { computePushPlan } from './push.mjs';
|
|
22
25
|
|
|
23
|
-
const PROJECTS_DIR = 'projects';
|
|
26
|
+
export const PROJECTS_DIR = 'projects';
|
|
24
27
|
const PROJECTS_MANIFEST = '.hiveku/projects.json';
|
|
25
28
|
|
|
26
29
|
const IGNORE_DIRS = new Set([
|
|
@@ -46,11 +49,11 @@ const RUN_HINTS = {
|
|
|
46
49
|
static: 'npx serve .',
|
|
47
50
|
};
|
|
48
51
|
|
|
49
|
-
function sha256(content) {
|
|
52
|
+
export function sha256(content) {
|
|
50
53
|
return createHash('sha256').update(content).digest('hex');
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
async function clientFromCwd({ profileOverride } = {}) {
|
|
56
|
+
export async function clientFromCwd({ profileOverride } = {}) {
|
|
54
57
|
const baseDir = process.cwd();
|
|
55
58
|
const dotenv = await loadDotenv(join(baseDir, '.env'));
|
|
56
59
|
const apiKey = readEnv(process.env, dotenv, 'OLYMPUS_API_KEY');
|
|
@@ -86,7 +89,7 @@ async function listAllRemoteFiles(client, projectId) {
|
|
|
86
89
|
return all;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
async function walkLocalFiles(rootDir) {
|
|
92
|
+
export async function walkLocalFiles(rootDir) {
|
|
90
93
|
const out = [];
|
|
91
94
|
async function recurse(dir) {
|
|
92
95
|
let entries;
|
|
@@ -125,16 +128,16 @@ function runHintForProjectType(type) {
|
|
|
125
128
|
return RUN_HINTS[type] || `# Unknown project_type "${type}". Inspect package.json/requirements.txt to find the dev command.`;
|
|
126
129
|
}
|
|
127
130
|
|
|
128
|
-
async function readProjectsManifest(baseDir) {
|
|
131
|
+
export async function readProjectsManifest(baseDir) {
|
|
129
132
|
const data = await readJson(join(baseDir, PROJECTS_MANIFEST));
|
|
130
133
|
return data && typeof data === 'object' ? data : { projects: {} };
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
async function writeProjectsManifest(baseDir, manifest) {
|
|
136
|
+
export async function writeProjectsManifest(baseDir, manifest) {
|
|
134
137
|
await writeFileAtomic(join(baseDir, PROJECTS_MANIFEST), JSON.stringify(manifest, null, 2));
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
async function resolveProject(client, slugOrId) {
|
|
140
|
+
export async function resolveProject(client, slugOrId) {
|
|
138
141
|
// Accept either UUID (use directly) or slug (look up via list_projects).
|
|
139
142
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
140
143
|
if (UUID_RE.test(slugOrId)) {
|
|
@@ -227,19 +230,22 @@ export async function runPullProject({ slugOrId, profile, branch }) {
|
|
|
227
230
|
runHintForProjectType(project.project_type),
|
|
228
231
|
'```',
|
|
229
232
|
'',
|
|
230
|
-
'##
|
|
233
|
+
'## Ship changes back',
|
|
231
234
|
'',
|
|
232
|
-
|
|
233
|
-
'
|
|
235
|
+
"This folder is NOT a git clone — it's a snapshot. `push` writes your edits to the",
|
|
236
|
+
'DEPLOY SOURCE (what `deploy` builds from), lane-routing images/fonts/video to the',
|
|
237
|
+
'S3/CDN asset store and everything else to versioned code files.',
|
|
234
238
|
'',
|
|
235
239
|
'From this folder:',
|
|
236
240
|
'',
|
|
237
241
|
'```bash',
|
|
238
|
-
|
|
239
|
-
|
|
242
|
+
'hiveku-sync pull-env # project secrets → .env.local',
|
|
243
|
+
'hiveku-sync status # diff local vs the deploy source',
|
|
244
|
+
'hiveku-sync push # save changes back',
|
|
245
|
+
'hiveku-sync push --deploy development --wait # ...then deploy and wait',
|
|
240
246
|
'```',
|
|
241
247
|
'',
|
|
242
|
-
'Or ask Claude in this workspace to
|
|
248
|
+
'Or ask Claude in this workspace to push/deploy for you.',
|
|
243
249
|
'',
|
|
244
250
|
]
|
|
245
251
|
.filter((line) => line !== undefined)
|
|
@@ -263,7 +269,7 @@ export async function runPullProject({ slugOrId, profile, branch }) {
|
|
|
263
269
|
return { dest, written, skipped, projectId, slug: localSlug };
|
|
264
270
|
}
|
|
265
271
|
|
|
266
|
-
async function findProjectFolder({ slug, baseDir }) {
|
|
272
|
+
export async function findProjectFolder({ slug, baseDir }) {
|
|
267
273
|
// Allow status/push to be called from inside ./projects/<slug>/, ./<slug>/,
|
|
268
274
|
// or the account root with an explicit slug arg.
|
|
269
275
|
const manifest = await readProjectsManifest(baseDir);
|
|
@@ -310,39 +316,68 @@ export async function runProjectStatus({ slug, profile, json = false }) {
|
|
|
310
316
|
throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
|
|
311
317
|
}
|
|
312
318
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
319
|
+
// Deploy-source-accurate + asset-lane-aware — the SAME diff `push` runs, so
|
|
320
|
+
// status never flags unchanged images as only_local (code goes through
|
|
321
|
+
// project_files_status; assets through assets_list).
|
|
322
|
+
const { diff, assetAll, assetToUpload, assetSkipped, plan } = await computePushPlan({
|
|
323
|
+
client,
|
|
324
|
+
projectId: info.project_id,
|
|
325
|
+
folder,
|
|
326
|
+
includeDeletes: true,
|
|
327
|
+
});
|
|
318
328
|
|
|
319
329
|
if (json) {
|
|
320
|
-
console.log(
|
|
321
|
-
|
|
330
|
+
console.log(
|
|
331
|
+
JSON.stringify(
|
|
332
|
+
{
|
|
333
|
+
slug: resolvedSlug,
|
|
334
|
+
project_id: info.project_id,
|
|
335
|
+
code: diff,
|
|
336
|
+
assets: {
|
|
337
|
+
to_upload: assetToUpload.map((f) => f.path),
|
|
338
|
+
unchanged: assetSkipped,
|
|
339
|
+
total_local: assetAll.length,
|
|
340
|
+
},
|
|
341
|
+
plan,
|
|
342
|
+
},
|
|
343
|
+
null,
|
|
344
|
+
2,
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
return { diff, plan };
|
|
322
348
|
}
|
|
323
349
|
|
|
324
|
-
const
|
|
350
|
+
const changed = diff.changed?.length || 0;
|
|
351
|
+
const onlyLocal = diff.only_local?.length || 0;
|
|
352
|
+
const onlyRemote = diff.only_remote?.length || 0;
|
|
353
|
+
const unchanged = diff.same_count || 0;
|
|
325
354
|
console.log(`Project: ${resolvedSlug} (${info.project_id})`);
|
|
326
|
-
console.log(
|
|
327
|
-
console.log(
|
|
328
|
-
console.log(`
|
|
329
|
-
console.log(`
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
355
|
+
console.log('Compared against the DEPLOY SOURCE (builder_code_versions + asset store).\n');
|
|
356
|
+
console.log('Code:');
|
|
357
|
+
console.log(` changed: ${changed}`);
|
|
358
|
+
console.log(` added: ${onlyLocal}`);
|
|
359
|
+
console.log(` remote-only: ${onlyRemote}`);
|
|
360
|
+
console.log(` unchanged: ${unchanged}`);
|
|
361
|
+
console.log('Assets (images/fonts/video under public/<subdir>/):');
|
|
362
|
+
console.log(` to upload: ${assetToUpload.length} (new or changed)`);
|
|
363
|
+
console.log(` unchanged: ${assetSkipped}`);
|
|
364
|
+
console.log(` total local: ${assetAll.length}`);
|
|
365
|
+
|
|
366
|
+
const printList = (items, marker, header, getPath = (f) => f.path) => {
|
|
367
|
+
if (!items || items.length === 0) return;
|
|
368
|
+
console.log(`\n${header}`);
|
|
369
|
+
for (const f of items.slice(0, 20)) console.log(` ${marker} ${getPath(f)}`);
|
|
370
|
+
if (items.length > 20) console.log(` ... and ${items.length - 20} more`);
|
|
371
|
+
};
|
|
372
|
+
printList(diff.changed, 'M', 'Changed code:');
|
|
373
|
+
printList(diff.only_local, '+', 'Added code (will be created on push):');
|
|
374
|
+
printList(
|
|
375
|
+
diff.only_remote,
|
|
376
|
+
'-',
|
|
377
|
+
'Remote-only code (deleted locally or never pulled — push --include-deletes to delete):',
|
|
378
|
+
);
|
|
379
|
+
printList(assetToUpload, 'A', 'Assets to upload (new or changed):');
|
|
380
|
+
return { diff, plan };
|
|
346
381
|
}
|
|
347
382
|
|
|
348
383
|
export async function runProjectPush({
|
package/src/push.mjs
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hiveku-sync` deploy-lane commands — the agent-native push/deploy/pull-env
|
|
3
|
+
* loop. This is the CLI equivalent of the VS Code extension's Source Control
|
|
4
|
+
* "Push", so a headless agent (Claude Code in a terminal) gets the SAME proven
|
|
5
|
+
* mechanics without hand-rolling an MCP client:
|
|
6
|
+
*
|
|
7
|
+
* push Save local changes to the DEPLOY SOURCE (builder_code_versions),
|
|
8
|
+
* lane-routed: CDN-servable binary (images/fonts/video under
|
|
9
|
+
* public/<subdir>/) → assets_upload (S3/CDN); everything else →
|
|
10
|
+
* batched, per-batch-verified project_files_bulk_save. This is what
|
|
11
|
+
* deploy_site actually reads — NOT project_commit's GitHub branch.
|
|
12
|
+
* deploy deploy_site + poll deploy_get to a terminal status (--wait).
|
|
13
|
+
* pull-env project_secrets_list → .env.local with the same _DEV/_PROD/
|
|
14
|
+
* _STAGING resolution the preview machine uses.
|
|
15
|
+
*
|
|
16
|
+
* Why not just project_commit (the old push)? project_commit is the GitHub
|
|
17
|
+
* path (needs a connected repo) AND sends every file in one unbatched call.
|
|
18
|
+
* bulk_save works on every project, batches for transport reliability, and —
|
|
19
|
+
* critically — never puts an image in builder_code_versions where it renders
|
|
20
|
+
* in the Fly preview but VANISHES on deploy (asset-build-bypass excludes it).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { readFile } from 'node:fs/promises';
|
|
26
|
+
import { join, relative, sep } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
clientFromCwd,
|
|
30
|
+
findProjectFolder,
|
|
31
|
+
resolveProject,
|
|
32
|
+
walkLocalFiles,
|
|
33
|
+
readProjectsManifest,
|
|
34
|
+
writeProjectsManifest,
|
|
35
|
+
} from './projects.mjs';
|
|
36
|
+
import { writeFileAtomic, readEnv, loadDotenv } from './util.mjs';
|
|
37
|
+
|
|
38
|
+
const PROJECTS_DIR = 'projects';
|
|
39
|
+
|
|
40
|
+
// ── Lane classification — mirrors the server's asset-build-bypass rule
|
|
41
|
+
// (hiveku_builder/src/lib/deployment/asset-build-bypass.ts). A file resolves
|
|
42
|
+
// into a public/ SUBDIRECTORY (public/<subdir>/...) → the CDN serves it and the
|
|
43
|
+
// build must NOT bundle it, so it belongs in the asset lane. src/ assets and
|
|
44
|
+
// public/ ROOT files (favicon.ico, robots.txt) stay code-lane. The BINARY guard
|
|
45
|
+
// is added on top because assets_upload rejects code MIME types — a text file
|
|
46
|
+
// under public/<subdir>/ (a .json/.html) still goes code-lane. This exactly
|
|
47
|
+
// matches the VS Code extension's verified predicate (scm.ts push()).
|
|
48
|
+
export function resolveAssetBuildPath(filePath) {
|
|
49
|
+
const clean = filePath.replace(/^\//, '');
|
|
50
|
+
return clean.startsWith('src/') || clean.startsWith('public/')
|
|
51
|
+
? clean
|
|
52
|
+
: `public/${clean.replace(/^public\//, '')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isCdnServableAssetPath(filePath) {
|
|
56
|
+
return /^public\/[^/]+\//.test(resolveAssetBuildPath(filePath));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The actual push routing decision: 'asset' → assets_upload (S3/CDN), 'code' →
|
|
61
|
+
* bulk_save. A file goes to the asset lane ONLY when it is BINARY *and* resolves
|
|
62
|
+
* under public/<subdir>/. The binary gate matters because assets_upload rejects
|
|
63
|
+
* code MIME types — a text file under public/<subdir>/ (a .json/.html) and every
|
|
64
|
+
* source file stays code-lane even though its path may resolve under public/.
|
|
65
|
+
*/
|
|
66
|
+
export function laneFor(filePath, encoding) {
|
|
67
|
+
return encoding === 'base64' && isCdnServableAssetPath(filePath) ? 'asset' : 'code';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Cheap, robust binary sniff: a NUL in the first 8KB, or a lossy utf-8 decode. */
|
|
71
|
+
export function looksBinary(buf) {
|
|
72
|
+
const n = Math.min(buf.length, 8000);
|
|
73
|
+
for (let i = 0; i < n; i++) {
|
|
74
|
+
if (buf[i] === 0) return true;
|
|
75
|
+
}
|
|
76
|
+
return buf.toString('utf8').includes('�');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sha256Hex(str) {
|
|
80
|
+
return createHash('sha256').update(str).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Read one file and shape it for a save/commit, choosing utf-8 vs base64. */
|
|
84
|
+
async function readForPush(root, rel) {
|
|
85
|
+
const buf = await readFile(join(root, rel));
|
|
86
|
+
const binary = looksBinary(buf);
|
|
87
|
+
return {
|
|
88
|
+
path: rel,
|
|
89
|
+
content: binary ? buf.toString('base64') : buf.toString('utf8'),
|
|
90
|
+
encoding: binary ? 'base64' : 'utf-8',
|
|
91
|
+
bytes: buf.length,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Concurrency-bounded map (no deps). Preserves input order in results.
|
|
96
|
+
async function runLimited(items, limit, fn) {
|
|
97
|
+
const results = new Array(items.length);
|
|
98
|
+
let next = 0;
|
|
99
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
100
|
+
while (true) {
|
|
101
|
+
const i = next++;
|
|
102
|
+
if (i >= items.length) return;
|
|
103
|
+
results[i] = await fn(items[i], i);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
await Promise.all(workers);
|
|
107
|
+
return results;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve the account baseDir + project folder the same way status/push do. */
|
|
111
|
+
function resolveBaseDir(cwd = process.cwd()) {
|
|
112
|
+
const idx = cwd.indexOf(`${sep}${PROJECTS_DIR}${sep}`);
|
|
113
|
+
return idx > -1 ? cwd.slice(0, idx) : cwd;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function localCodeManifest(codeFiles) {
|
|
117
|
+
// codeFiles already read (utf-8). sha256 of the content string, matching the
|
|
118
|
+
// server's project_files_status hashing (and hiveku-sync's existing status).
|
|
119
|
+
return codeFiles.map((f) => ({ path: f.path, sha256: sha256Hex(f.content) }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── assets_list → { resolvedPath -> byteSize } so we can skip re-uploading
|
|
123
|
+
// unchanged images instead of flagging all 468 as only_local (the noise the
|
|
124
|
+
// momentum session hit). assets_upload mirrors non-public paths under public/,
|
|
125
|
+
// so we compare on the resolved public/ form. Size is the change signal —
|
|
126
|
+
// assets_list doesn't expose a content hash, and a same-size same-path asset is
|
|
127
|
+
// treated as unchanged (documented heuristic; --assets-all forces re-upload).
|
|
128
|
+
async function remoteAssetMap(client, projectId) {
|
|
129
|
+
const map = new Map();
|
|
130
|
+
let page = 1;
|
|
131
|
+
const limit = 200;
|
|
132
|
+
while (true) {
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = await client.callToolJson('assets_list', { project_id: projectId, page, limit });
|
|
136
|
+
} catch {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const batch = result?.data || result?.assets || [];
|
|
140
|
+
for (const a of batch) {
|
|
141
|
+
const p = a.file_path || a.path;
|
|
142
|
+
if (!p) continue;
|
|
143
|
+
const size = a.file_size ?? a.size ?? a.bytes ?? null;
|
|
144
|
+
map.set(resolveAssetBuildPath(p), size);
|
|
145
|
+
}
|
|
146
|
+
const totalPages =
|
|
147
|
+
result?.pagination?.total_pages ?? result?.pagination?.pages ?? 1;
|
|
148
|
+
if (batch.length === 0 || page >= totalPages) break;
|
|
149
|
+
page += 1;
|
|
150
|
+
}
|
|
151
|
+
return map;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compute what a push WOULD do — the deploy-source-accurate, asset-lane-aware
|
|
156
|
+
* diff shared by `push` and `status`. No writes. Returns the classified local
|
|
157
|
+
* files, the code diff (one status call), the asset diff (assets_list), and a
|
|
158
|
+
* summary plan.
|
|
159
|
+
*/
|
|
160
|
+
export async function computePushPlan({ client, projectId, folder, includeDeletes = false, noAssets = false, assetsAll = false }) {
|
|
161
|
+
// 1. Read + lane-classify every local file.
|
|
162
|
+
const rels = (await walkLocalFiles(folder)).map((abs) =>
|
|
163
|
+
relative(folder, abs).split(sep).join('/'),
|
|
164
|
+
);
|
|
165
|
+
const codeAll = [];
|
|
166
|
+
const assetAll = [];
|
|
167
|
+
for (const rel of rels) {
|
|
168
|
+
const f = await readForPush(folder, rel);
|
|
169
|
+
if (laneFor(rel, f.encoding) === 'asset') assetAll.push(f);
|
|
170
|
+
else codeAll.push(f);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. CODE diff — ONE project_files_status call (never chunked: only_remote is
|
|
174
|
+
// computed against the whole manifest, so chunking produces phantom
|
|
175
|
+
// deletions — the exact gotcha the momentum session hit). Only CODE files
|
|
176
|
+
// are sent, so binary assets never show up as false only_local.
|
|
177
|
+
const diff = (
|
|
178
|
+
await client.callToolJson('project_files_status', {
|
|
179
|
+
project_id: projectId,
|
|
180
|
+
local: await localCodeManifest(codeAll),
|
|
181
|
+
})
|
|
182
|
+
).data;
|
|
183
|
+
const changedSet = new Set([
|
|
184
|
+
...(diff.changed || []).map((c) => c.path),
|
|
185
|
+
...(diff.only_local || []).map((c) => c.path),
|
|
186
|
+
]);
|
|
187
|
+
const codeToSave = codeAll.filter((f) => changedSet.has(f.path));
|
|
188
|
+
// only_remote = code-lane paths (builder_code_versions) missing locally —
|
|
189
|
+
// status never reports binary assets (those live in builder_project_assets),
|
|
190
|
+
// so every only_remote entry is a genuine code deletion. If a public/<subdir>/
|
|
191
|
+
// image DOES show up here, it's a stale code_versions duplicate (the VANISH
|
|
192
|
+
// cause) and deleting it is correct — project_file_delete only touches
|
|
193
|
+
// code_versions, never the CDN asset we just uploaded.
|
|
194
|
+
const codeToDelete = includeDeletes ? (diff.only_remote || []).map((c) => c.path) : [];
|
|
195
|
+
|
|
196
|
+
// 3. ASSET diff — asset-lane-aware, so unchanged images aren't re-sent.
|
|
197
|
+
const assetToUpload = [];
|
|
198
|
+
let assetSkipped = 0;
|
|
199
|
+
if (!noAssets && assetAll.length > 0) {
|
|
200
|
+
const remote = await remoteAssetMap(client, projectId);
|
|
201
|
+
for (const f of assetAll) {
|
|
202
|
+
const key = resolveAssetBuildPath(f.path);
|
|
203
|
+
if (assetsAll) {
|
|
204
|
+
assetToUpload.push(f);
|
|
205
|
+
} else if (!remote.has(key)) {
|
|
206
|
+
assetToUpload.push(f); // new
|
|
207
|
+
} else {
|
|
208
|
+
const size = remote.get(key);
|
|
209
|
+
if (size != null && Number(size) !== f.bytes) assetToUpload.push(f); // size changed
|
|
210
|
+
else assetSkipped += 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const plan = {
|
|
216
|
+
project_id: projectId,
|
|
217
|
+
code_save: codeToSave.length,
|
|
218
|
+
code_delete: codeToDelete.length,
|
|
219
|
+
asset_upload: assetToUpload.length,
|
|
220
|
+
asset_unchanged: assetSkipped,
|
|
221
|
+
asset_skipped_lane: noAssets ? assetAll.length : 0,
|
|
222
|
+
};
|
|
223
|
+
return { codeAll, assetAll, diff, codeToSave, codeToDelete, assetToUpload, assetSkipped, plan };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Thin CLI wrapper: resolve the MCP client + project folder from cwd, then run
|
|
227
|
+
// the (fully testable) orchestration in pushProject.
|
|
228
|
+
export async function runPush({ slug, profile, ...opts } = {}) {
|
|
229
|
+
const baseDir = resolveBaseDir();
|
|
230
|
+
const { client } = await clientFromCwd({ profileOverride: profile });
|
|
231
|
+
const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
|
|
232
|
+
if (!existsSync(folder)) {
|
|
233
|
+
throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
|
|
234
|
+
}
|
|
235
|
+
return pushProject({
|
|
236
|
+
client,
|
|
237
|
+
projectId: info.project_id,
|
|
238
|
+
slug: resolvedSlug,
|
|
239
|
+
folder,
|
|
240
|
+
baseDir,
|
|
241
|
+
info,
|
|
242
|
+
...opts,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Core push orchestration — no cwd/network resolution, so it can be driven with
|
|
248
|
+
* a fake MCP client + temp folder in tests. Lane-classifies local files, diffs
|
|
249
|
+
* code (one status call) + assets (assets_list), then batches bulk_save +
|
|
250
|
+
* assets_upload + deletions, with optional commit marker + deploy.
|
|
251
|
+
*/
|
|
252
|
+
export async function pushProject({
|
|
253
|
+
client,
|
|
254
|
+
projectId,
|
|
255
|
+
slug: resolvedSlug,
|
|
256
|
+
folder,
|
|
257
|
+
baseDir,
|
|
258
|
+
info = {},
|
|
259
|
+
commit,
|
|
260
|
+
deploy,
|
|
261
|
+
wait = false,
|
|
262
|
+
includeDeletes = false,
|
|
263
|
+
noAssets = false,
|
|
264
|
+
assetsAll = false,
|
|
265
|
+
dryRun = false,
|
|
266
|
+
branch,
|
|
267
|
+
}) {
|
|
268
|
+
const {
|
|
269
|
+
codeAll,
|
|
270
|
+
assetAll,
|
|
271
|
+
codeToSave,
|
|
272
|
+
codeToDelete,
|
|
273
|
+
assetToUpload,
|
|
274
|
+
assetSkipped,
|
|
275
|
+
plan: basePlan,
|
|
276
|
+
} = await computePushPlan({ client, projectId, folder, includeDeletes, noAssets, assetsAll });
|
|
277
|
+
const plan = { project: resolvedSlug, ...basePlan };
|
|
278
|
+
|
|
279
|
+
if (dryRun) {
|
|
280
|
+
console.log('[dry run] push plan:');
|
|
281
|
+
console.log(` code save: ${plan.code_save}`);
|
|
282
|
+
console.log(` code delete: ${plan.code_delete}${includeDeletes ? '' : ' (pass --include-deletes to apply)'}`);
|
|
283
|
+
console.log(` asset upload: ${plan.asset_upload}`);
|
|
284
|
+
console.log(` asset skip: ${plan.asset_unchanged} unchanged${noAssets ? `, ${assetAll.length} lane-skipped (--no-assets)` : ''}`);
|
|
285
|
+
if (codeToSave.length) {
|
|
286
|
+
console.log('\n files to save:');
|
|
287
|
+
for (const f of codeToSave.slice(0, 30)) console.log(` M ${f.path}`);
|
|
288
|
+
if (codeToSave.length > 30) console.log(` ... and ${codeToSave.length - 30} more`);
|
|
289
|
+
}
|
|
290
|
+
if (assetToUpload.length) {
|
|
291
|
+
console.log('\n assets to upload:');
|
|
292
|
+
for (const f of assetToUpload.slice(0, 20)) console.log(` A ${f.path}`);
|
|
293
|
+
if (assetToUpload.length > 20) console.log(` ... and ${assetToUpload.length - 20} more`);
|
|
294
|
+
}
|
|
295
|
+
return { dryRun: true, plan };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (codeToSave.length === 0 && codeToDelete.length === 0 && assetToUpload.length === 0) {
|
|
299
|
+
console.log('Nothing to push — local is in sync with the deploy source.');
|
|
300
|
+
if (deploy) return runDeployFor({ client, projectId, slug: resolvedSlug, environment: deploy, wait });
|
|
301
|
+
return { skipped: true, plan };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const failedPaths = [];
|
|
305
|
+
let codeSaved = 0;
|
|
306
|
+
|
|
307
|
+
// ── Code lane: batched, per-batch-verified bulk_save ──────────────────────
|
|
308
|
+
const BATCH_FILES = 40;
|
|
309
|
+
const BATCH_BYTES = 4_000_000; // well under the server's 20MB cap; sized for MCP-transport reliability
|
|
310
|
+
let batch = [];
|
|
311
|
+
let batchBytes = 0;
|
|
312
|
+
let batchNo = 0;
|
|
313
|
+
const flush = async () => {
|
|
314
|
+
if (batch.length === 0) return;
|
|
315
|
+
batchNo += 1;
|
|
316
|
+
const files = batch.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding }));
|
|
317
|
+
const label = batch;
|
|
318
|
+
batch = [];
|
|
319
|
+
batchBytes = 0;
|
|
320
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
321
|
+
let r;
|
|
322
|
+
try {
|
|
323
|
+
r = (
|
|
324
|
+
await client.callToolJson('project_files_bulk_save', {
|
|
325
|
+
project_id: projectId,
|
|
326
|
+
files,
|
|
327
|
+
commit_message: commit || 'hiveku-sync: push local changes',
|
|
328
|
+
})
|
|
329
|
+
).data;
|
|
330
|
+
} catch (e) {
|
|
331
|
+
if (attempt >= 2) failedPaths.push(...label.map((f) => f.path));
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const succeeded = r?.summary?.succeeded ?? files.length;
|
|
335
|
+
codeSaved += succeeded;
|
|
336
|
+
if (succeeded === files.length) return;
|
|
337
|
+
const bad = (r?.results || []).filter((x) => x.ok === false).map((x) => x.path);
|
|
338
|
+
if (attempt >= 2) failedPaths.push(...(bad.length ? bad : label.map((f) => f.path)));
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
for (const f of codeToSave) {
|
|
342
|
+
if (batch.length >= BATCH_FILES || (batchBytes > 0 && batchBytes + f.content.length > BATCH_BYTES)) {
|
|
343
|
+
await flush();
|
|
344
|
+
console.log(` code ${codeSaved}/${codeToSave.length}`);
|
|
345
|
+
}
|
|
346
|
+
batch.push(f);
|
|
347
|
+
batchBytes += f.content.length;
|
|
348
|
+
}
|
|
349
|
+
await flush();
|
|
350
|
+
|
|
351
|
+
// ── Asset lane: assets_upload, bounded concurrency, retry once ────────────
|
|
352
|
+
let assetDone = 0;
|
|
353
|
+
await runLimited(assetToUpload, 5, async (f) => {
|
|
354
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
355
|
+
try {
|
|
356
|
+
await client.callToolJson('assets_upload', {
|
|
357
|
+
project_id: projectId,
|
|
358
|
+
file_path: f.path,
|
|
359
|
+
content: f.content,
|
|
360
|
+
});
|
|
361
|
+
assetDone += 1;
|
|
362
|
+
if (assetDone % 10 === 0 || assetDone === assetToUpload.length) {
|
|
363
|
+
console.log(` assets ${assetDone}/${assetToUpload.length}`);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
} catch (e) {
|
|
367
|
+
if (attempt >= 2) failedPaths.push(f.path);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── Deletions (code lane only) ────────────────────────────────────────────
|
|
373
|
+
let deleted = 0;
|
|
374
|
+
for (const p of codeToDelete) {
|
|
375
|
+
try {
|
|
376
|
+
await client.callToolJson('project_file_delete', { project_id: projectId, file_path: p });
|
|
377
|
+
deleted += 1;
|
|
378
|
+
} catch {
|
|
379
|
+
failedPaths.push(p);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
console.log(
|
|
384
|
+
`Pushed: ${codeSaved} code file${codeSaved !== 1 ? 's' : ''}` +
|
|
385
|
+
`, ${assetDone} asset${assetDone !== 1 ? 's' : ''}` +
|
|
386
|
+
(deleted ? `, ${deleted} deletion${deleted !== 1 ? 's' : ''}` : '') +
|
|
387
|
+
(assetSkipped ? ` (${assetSkipped} asset${assetSkipped !== 1 ? 's' : ''} unchanged)` : ''),
|
|
388
|
+
);
|
|
389
|
+
if (failedPaths.length) {
|
|
390
|
+
console.log(` ⚠ ${failedPaths.length} file(s) FAILED: ${failedPaths.slice(0, 10).join(', ')}${failedPaths.length > 10 ? '…' : ''}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Optional: record a Hiveku-native main commit (version-history marker).
|
|
394
|
+
// Files are already saved via bulk_save; this snapshots the current state on
|
|
395
|
+
// `main` so `hiveku-sync` history shows the push. GitHub not required.
|
|
396
|
+
if (commit) {
|
|
397
|
+
try {
|
|
398
|
+
const c = (
|
|
399
|
+
await client.callToolJson('project_vcs_commit', {
|
|
400
|
+
project_id: projectId,
|
|
401
|
+
message: commit,
|
|
402
|
+
branch: branch || info.branch || undefined,
|
|
403
|
+
deletedFiles: codeToDelete.length ? codeToDelete : undefined,
|
|
404
|
+
})
|
|
405
|
+
).data;
|
|
406
|
+
console.log(` committed on ${c?.branch_name || branch || 'main'} (${c?.checkpoint_hash ? c.checkpoint_hash.slice(0, 12) : c?.id || 'recorded'})`);
|
|
407
|
+
} catch (e) {
|
|
408
|
+
console.log(` ⚠ commit marker failed (files ARE saved): ${(e && e.message) || e}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Update manifest timestamps.
|
|
413
|
+
const manifest = await readProjectsManifest(baseDir);
|
|
414
|
+
manifest.projects = manifest.projects || {};
|
|
415
|
+
manifest.projects[resolvedSlug] = {
|
|
416
|
+
...(manifest.projects[resolvedSlug] || { project_id: projectId }),
|
|
417
|
+
last_push_at: new Date().toISOString(),
|
|
418
|
+
};
|
|
419
|
+
await writeProjectsManifest(baseDir, manifest);
|
|
420
|
+
|
|
421
|
+
const result = { plan, code_saved: codeSaved, assets_uploaded: assetDone, deleted, failed: failedPaths };
|
|
422
|
+
if (deploy) {
|
|
423
|
+
const d = await runDeployFor({ client, projectId, slug: resolvedSlug, environment: deploy, wait });
|
|
424
|
+
result.deploy = d;
|
|
425
|
+
}
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Deploy ──────────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
const DEPLOY_SUCCESS = new Set(['completed', 'ready', 'succeeded', 'success', 'deployed', 'live']);
|
|
432
|
+
const DEPLOY_FAILURE = new Set(['failed', 'error', 'errored', 'cancelled', 'canceled', 'timed_out']);
|
|
433
|
+
|
|
434
|
+
export function normalizeEnv(env) {
|
|
435
|
+
const e = String(env || 'development').toLowerCase();
|
|
436
|
+
if (e === 'preview' || e === 'dev-preview' || e === 'dev') return 'development';
|
|
437
|
+
if (e === 'prod') return 'production';
|
|
438
|
+
if (e === 'stage') return 'staging';
|
|
439
|
+
return e;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function runDeployFor({ client, projectId, slug, environment, wait }) {
|
|
443
|
+
const env = normalizeEnv(environment);
|
|
444
|
+
if (!['development', 'staging', 'production'].includes(env)) {
|
|
445
|
+
throw new Error(`Invalid environment "${environment}" — use development | staging | production.`);
|
|
446
|
+
}
|
|
447
|
+
console.log(`Deploying ${slug} → ${env}…`);
|
|
448
|
+
const started = (
|
|
449
|
+
await client.callToolJson('deploy_site', { project_id: projectId, environment: env })
|
|
450
|
+
).data;
|
|
451
|
+
const deploymentId = started?.deployment_id || started?.deploy_id || started?.id;
|
|
452
|
+
const startUrl = started?.url || started?.preview_url;
|
|
453
|
+
console.log(` deployment_id: ${deploymentId || '(none returned)'}`);
|
|
454
|
+
if (startUrl) console.log(` url: ${startUrl}`);
|
|
455
|
+
|
|
456
|
+
if (!wait) {
|
|
457
|
+
console.log(' (not waiting — pass --wait to poll to completion)');
|
|
458
|
+
return { deployment_id: deploymentId, environment: env, url: startUrl, waited: false };
|
|
459
|
+
}
|
|
460
|
+
if (!deploymentId) {
|
|
461
|
+
console.log(' ⚠ no deployment_id to poll — check deploy_status manually.');
|
|
462
|
+
return { environment: env, waited: false };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const POLL_MS = 8000;
|
|
466
|
+
const MAX_MS = env === 'production' ? 30 * 60 * 1000 : 15 * 60 * 1000;
|
|
467
|
+
const t0 = Date.now();
|
|
468
|
+
let last = '';
|
|
469
|
+
while (Date.now() - t0 < MAX_MS) {
|
|
470
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
471
|
+
let dep;
|
|
472
|
+
try {
|
|
473
|
+
dep = (await client.callToolJson('deploy_get', { project_id: projectId, deployment_id: deploymentId })).data;
|
|
474
|
+
} catch {
|
|
475
|
+
continue; // transient — keep polling
|
|
476
|
+
}
|
|
477
|
+
const status = String(dep?.status || 'queued').toLowerCase();
|
|
478
|
+
if (status !== last) {
|
|
479
|
+
const secs = Math.round((Date.now() - t0) / 1000);
|
|
480
|
+
console.log(` [${secs}s] ${status}`);
|
|
481
|
+
last = status;
|
|
482
|
+
}
|
|
483
|
+
if (DEPLOY_SUCCESS.has(status)) {
|
|
484
|
+
const url = dep?.url || dep?.preview_url || startUrl;
|
|
485
|
+
console.log(` ✓ deployed: ${url || '(url pending)'}`);
|
|
486
|
+
return { deployment_id: deploymentId, environment: env, status, url, waited: true, ok: true };
|
|
487
|
+
}
|
|
488
|
+
if (DEPLOY_FAILURE.has(status)) {
|
|
489
|
+
console.log(` ✗ deploy ${status}${dep?.error ? `: ${dep.error}` : ''}`);
|
|
490
|
+
if (dep?.build_logs) {
|
|
491
|
+
const tail = String(dep.build_logs).split('\n').slice(-15).join('\n');
|
|
492
|
+
console.log(' --- build log (tail) ---\n' + tail);
|
|
493
|
+
}
|
|
494
|
+
const err = new Error(`Deploy ${status}${dep?.error ? `: ${dep.error}` : ''}`);
|
|
495
|
+
err.deploy = { deployment_id: deploymentId, environment: env, status, error: dep?.error };
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
console.log(` ⚠ still ${last || 'pending'} after ${Math.round(MAX_MS / 60000)}min — check deploy_status.`);
|
|
500
|
+
return { deployment_id: deploymentId, environment: env, status: last, waited: true, ok: false };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function runDeploy({ slugOrId, environment, wait = false, profile }) {
|
|
504
|
+
if (!environment) throw new Error('Usage: hiveku-sync deploy <development|staging|production> [<slug>] [--wait]');
|
|
505
|
+
const baseDir = resolveBaseDir();
|
|
506
|
+
const { client } = await clientFromCwd({ profileOverride: profile });
|
|
507
|
+
|
|
508
|
+
// Resolve project: prefer the pulled-folder manifest (slug), else treat the
|
|
509
|
+
// arg as a slug/UUID via list_projects.
|
|
510
|
+
let projectId;
|
|
511
|
+
let slug = slugOrId;
|
|
512
|
+
try {
|
|
513
|
+
const { slug: s, info } = await findProjectFolder({ slug: slugOrId, baseDir });
|
|
514
|
+
projectId = info.project_id;
|
|
515
|
+
slug = s;
|
|
516
|
+
} catch {
|
|
517
|
+
if (!slugOrId) throw new Error('No project folder detected — pass a project slug or UUID: hiveku-sync deploy <env> <slug>');
|
|
518
|
+
const project = await resolveProject(client, slugOrId);
|
|
519
|
+
projectId = project.id;
|
|
520
|
+
slug = project.slug || project.name || project.id;
|
|
521
|
+
}
|
|
522
|
+
return runDeployFor({ client, projectId, slug, environment, wait });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── pull-env ──────────────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
// Resolution mirrors the preview machine (project_secrets_set): _DEV wins for
|
|
528
|
+
// development and is stripped to its base key; _PROD/_PRODUCTION are for
|
|
529
|
+
// production, _STAGING for staging. Suffixes for OTHER tiers are skipped.
|
|
530
|
+
const TIER_SUFFIX = {
|
|
531
|
+
development: ['_DEV'],
|
|
532
|
+
staging: ['_STAGING'],
|
|
533
|
+
production: ['_PROD', '_PRODUCTION'],
|
|
534
|
+
};
|
|
535
|
+
const ALL_SUFFIXES = ['_DEV', '_STAGING', '_PROD', '_PRODUCTION'];
|
|
536
|
+
|
|
537
|
+
export function resolveSecretsForTier(secrets, tier) {
|
|
538
|
+
const want = TIER_SUFFIX[tier] || TIER_SUFFIX.development;
|
|
539
|
+
const base = {};
|
|
540
|
+
const overrides = {};
|
|
541
|
+
for (const [key, value] of Object.entries(secrets || {})) {
|
|
542
|
+
const suffix = ALL_SUFFIXES.find((s) => key.endsWith(s));
|
|
543
|
+
if (!suffix) {
|
|
544
|
+
base[key] = value;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (want.includes(suffix)) {
|
|
548
|
+
overrides[key.slice(0, -suffix.length)] = value; // strip suffix → base name
|
|
549
|
+
}
|
|
550
|
+
// suffix for another tier → skip
|
|
551
|
+
}
|
|
552
|
+
return { ...base, ...overrides }; // tier override wins over the base key
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function envFileForTier(tier) {
|
|
556
|
+
if (tier === 'production') return '.env.production.local';
|
|
557
|
+
if (tier === 'staging') return '.env.staging.local';
|
|
558
|
+
return '.env.local';
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function serializeEnv(map) {
|
|
562
|
+
const lines = [
|
|
563
|
+
'# Generated by `hiveku-sync pull-env` from Hiveku project secrets.',
|
|
564
|
+
'# Do NOT commit — this file is git-ignored.',
|
|
565
|
+
'',
|
|
566
|
+
];
|
|
567
|
+
for (const key of Object.keys(map).sort()) {
|
|
568
|
+
let v = String(map[key] ?? '');
|
|
569
|
+
// Quote if it contains whitespace, #, quotes, or newlines.
|
|
570
|
+
if (/[\s#'"\n]/.test(v)) {
|
|
571
|
+
v = `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
|
|
572
|
+
}
|
|
573
|
+
lines.push(`${key}=${v}`);
|
|
574
|
+
}
|
|
575
|
+
return lines.join('\n') + '\n';
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export async function runPullEnv({ slug, profile, target = 'development' } = {}) {
|
|
579
|
+
const tier = normalizeEnv(target);
|
|
580
|
+
if (!['development', 'staging', 'production'].includes(tier)) {
|
|
581
|
+
throw new Error(`Invalid --target "${target}" — use development | staging | production.`);
|
|
582
|
+
}
|
|
583
|
+
const baseDir = resolveBaseDir();
|
|
584
|
+
const { client } = await clientFromCwd({ profileOverride: profile });
|
|
585
|
+
const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
|
|
586
|
+
const projectId = info.project_id;
|
|
587
|
+
|
|
588
|
+
const res = (await client.callToolJson('project_secrets_list', { project_id: projectId })).data;
|
|
589
|
+
const secrets = res?.secrets || {};
|
|
590
|
+
const count = Object.keys(secrets).length;
|
|
591
|
+
if (count === 0) {
|
|
592
|
+
console.log(`No secrets set on ${resolvedSlug}. Nothing to write.`);
|
|
593
|
+
return { written: 0 };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const resolved = resolveSecretsForTier(secrets, tier);
|
|
597
|
+
const outName = envFileForTier(tier);
|
|
598
|
+
const outPath = join(folder, outName);
|
|
599
|
+
await writeFileAtomic(outPath, serializeEnv(resolved));
|
|
600
|
+
|
|
601
|
+
// Make sure the env file is git-ignored so it never gets pushed.
|
|
602
|
+
const gitignorePath = join(folder, '.gitignore');
|
|
603
|
+
let ignore = '';
|
|
604
|
+
try {
|
|
605
|
+
ignore = await readFile(gitignorePath, 'utf8');
|
|
606
|
+
} catch {
|
|
607
|
+
/* no .gitignore yet */
|
|
608
|
+
}
|
|
609
|
+
if (!ignore.split('\n').some((l) => l.trim() === outName)) {
|
|
610
|
+
await writeFileAtomic(
|
|
611
|
+
gitignorePath,
|
|
612
|
+
(ignore && !ignore.endsWith('\n') ? ignore + '\n' : ignore) + outName + '\n',
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
console.log(`Wrote ${Object.keys(resolved).length} var${Object.keys(resolved).length !== 1 ? 's' : ''} → ${outName} (tier: ${tier}, from ${count} secret${count !== 1 ? 's' : ''}).`);
|
|
617
|
+
return { written: Object.keys(resolved).length, path: outPath, tier };
|
|
618
|
+
}
|