@hogsend/cli 0.0.1 → 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/dist/bin.js +2238 -75
- package/dist/bin.js.map +1 -1
- package/package.json +9 -1
- package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
- package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
- package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
- package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
- package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
- package/skills/hogsend-authoring-emails/SKILL.md +68 -0
- package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
- package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
- package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
- package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
- package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +93 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +110 -0
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +142 -0
- package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
- package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
- package/skills/hogsend-cli/SKILL.md +81 -0
- package/skills/hogsend-cli/references/debug-a-journey.md +66 -0
- package/skills/hogsend-cli/references/manage-journeys.md +53 -0
- package/skills/hogsend-cli/references/query-stats.md +66 -0
- package/skills/hogsend-cli/references/setup-local.md +52 -0
- package/skills/hogsend-conditions/SKILL.md +70 -0
- package/skills/hogsend-conditions/references/condition-types.md +251 -0
- package/skills/hogsend-conditions/references/durations.md +90 -0
- package/skills/hogsend-conditions/references/examples.md +188 -0
- package/skills/hogsend-database/SKILL.md +70 -0
- package/skills/hogsend-database/references/client-track-schema.md +97 -0
- package/skills/hogsend-database/references/migrations.md +132 -0
- package/skills/hogsend-database/references/schema-drift.md +123 -0
- package/skills/hogsend-deploy/SKILL.md +62 -0
- package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
- package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
- package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
- package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
- package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
- package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
- package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
- package/src/bin.ts +73 -111
- package/src/commands/contacts.ts +316 -0
- package/src/commands/doctor.ts +239 -0
- package/src/commands/eject.ts +106 -0
- package/src/commands/events.ts +154 -0
- package/src/commands/index.ts +36 -0
- package/src/commands/journeys.ts +343 -0
- package/src/commands/patch.ts +80 -0
- package/src/commands/setup.ts +322 -0
- package/src/commands/skills.ts +208 -0
- package/src/commands/stats.ts +87 -0
- package/src/commands/studio.ts +261 -0
- package/src/commands/types.ts +41 -0
- package/src/commands/upgrade.ts +245 -0
- package/src/index.ts +2 -0
- package/src/lib/config.ts +147 -0
- package/src/lib/http.ts +145 -0
- package/src/lib/output.ts +185 -0
- package/src/lib/prompt.ts +17 -0
- package/src/lib/skills.ts +186 -0
- package/studio/assets/index-BVA9GZqq.css +1 -0
- package/studio/assets/index-kPwzOOyG.js +230 -0
- package/studio/index.html +13 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { color } from "../lib/output.js";
|
|
8
|
+
import type { Command, CommandContext } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const usage = `hogsend studio [options]
|
|
11
|
+
|
|
12
|
+
Serve the bundled Hogsend Studio (the admin SPA) locally and open it in a
|
|
13
|
+
browser. The Studio is a static single-page app; this command starts a tiny
|
|
14
|
+
local web server for it on a port of your choosing.
|
|
15
|
+
|
|
16
|
+
By default the Studio talks to the API at the same origin it is served from,
|
|
17
|
+
which won't be a running API here — so point it at your instance with
|
|
18
|
+
--base-url (the SPA uses cookie auth, so the instance must allow CORS from the
|
|
19
|
+
Studio origin, or you can simply open the Studio that the engine mounts at
|
|
20
|
+
\`<instance>/studio\` instead).
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--port <n> Local port to serve on (default 3333).
|
|
24
|
+
--base-url <url> API instance the Studio should call (injected at runtime).
|
|
25
|
+
Omit to use same-origin (the local server, for static
|
|
26
|
+
preview only).
|
|
27
|
+
--open Open the Studio in your default browser after starting.
|
|
28
|
+
--dist <path> Override the Studio dist directory (advanced).
|
|
29
|
+
-h, --help Show this help.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
hogsend studio --open
|
|
33
|
+
hogsend studio --base-url https://api.example.com --open
|
|
34
|
+
hogsend studio --port 4000`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the built Studio `dist/` directory.
|
|
38
|
+
*
|
|
39
|
+
* Resolution order:
|
|
40
|
+
* 1. Explicit --dist override (positional path; absolute or cwd-relative).
|
|
41
|
+
* 2. The dist bundled inside this CLI package (shipped via package.json files[];
|
|
42
|
+
* at runtime bin.js is <pkg>/dist/bin.js, so the bundled studio is one level
|
|
43
|
+
* up at <pkg>/studio).
|
|
44
|
+
* 3. Monorepo source layout: packages/studio/dist relative to this file.
|
|
45
|
+
* 4. cwd-relative packages/studio/dist (running from repo root).
|
|
46
|
+
*/
|
|
47
|
+
function resolveStudioDist(distFlag: string | undefined): string | null {
|
|
48
|
+
const candidates: string[] = [];
|
|
49
|
+
|
|
50
|
+
if (distFlag && distFlag.length > 0) {
|
|
51
|
+
candidates.push(resolve(process.cwd(), distFlag));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Bundled in the published CLI tarball at <pkg>/studio.
|
|
55
|
+
candidates.push(fileURLToPath(new URL("../studio", import.meta.url)));
|
|
56
|
+
|
|
57
|
+
// Monorepo: this file is packages/cli/src/commands/studio.ts (or built into
|
|
58
|
+
// dist/), so the studio dist sits at ../../studio/dist relative to dist/.
|
|
59
|
+
candidates.push(
|
|
60
|
+
fileURLToPath(new URL("../../studio/dist", import.meta.url)),
|
|
61
|
+
fileURLToPath(new URL("../../../studio/dist", import.meta.url)),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
candidates.push(resolve(process.cwd(), "packages/studio/dist"));
|
|
65
|
+
|
|
66
|
+
for (const dir of candidates) {
|
|
67
|
+
if (existsSync(join(dir, "index.html"))) {
|
|
68
|
+
return dir;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const MIME: Record<string, string> = {
|
|
75
|
+
".html": "text/html; charset=utf-8",
|
|
76
|
+
".js": "text/javascript; charset=utf-8",
|
|
77
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
78
|
+
".css": "text/css; charset=utf-8",
|
|
79
|
+
".json": "application/json; charset=utf-8",
|
|
80
|
+
".svg": "image/svg+xml",
|
|
81
|
+
".png": "image/png",
|
|
82
|
+
".jpg": "image/jpeg",
|
|
83
|
+
".jpeg": "image/jpeg",
|
|
84
|
+
".gif": "image/gif",
|
|
85
|
+
".ico": "image/x-icon",
|
|
86
|
+
".woff": "font/woff",
|
|
87
|
+
".woff2": "font/woff2",
|
|
88
|
+
".ttf": "font/ttf",
|
|
89
|
+
".map": "application/json; charset=utf-8",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function mimeFor(path: string): string {
|
|
93
|
+
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read index.html and, when a base URL is provided, inject a runtime global the
|
|
98
|
+
* Studio reads (`window.__HOGSEND_STUDIO__ = { baseUrl }`) so the static bundle
|
|
99
|
+
* can be pointed at a remote instance without a rebuild.
|
|
100
|
+
*/
|
|
101
|
+
function indexHtml(distPath: string, baseUrl: string | undefined): string {
|
|
102
|
+
const raw = readFileSync(join(distPath, "index.html"), "utf8");
|
|
103
|
+
if (!baseUrl) return raw;
|
|
104
|
+
const inject = `<script>window.__HOGSEND_STUDIO__=${JSON.stringify({
|
|
105
|
+
baseUrl,
|
|
106
|
+
})};</script>`;
|
|
107
|
+
if (raw.includes("</head>")) {
|
|
108
|
+
return raw.replace("</head>", `${inject}</head>`);
|
|
109
|
+
}
|
|
110
|
+
return `${inject}${raw}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Open a URL in the OS default browser (best-effort, never throws). */
|
|
114
|
+
function openBrowser(url: string): void {
|
|
115
|
+
const platform = process.platform;
|
|
116
|
+
const cmd =
|
|
117
|
+
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
118
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
119
|
+
try {
|
|
120
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
121
|
+
child.on("error", () => {});
|
|
122
|
+
child.unref();
|
|
123
|
+
} catch {
|
|
124
|
+
// best-effort
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
129
|
+
const { values, positionals } = parseArgs({
|
|
130
|
+
args: ctx.argv,
|
|
131
|
+
allowPositionals: true,
|
|
132
|
+
strict: false,
|
|
133
|
+
options: {
|
|
134
|
+
port: { type: "string" },
|
|
135
|
+
"base-url": { type: "string" },
|
|
136
|
+
open: { type: "boolean", default: false },
|
|
137
|
+
dist: { type: "string" },
|
|
138
|
+
help: { type: "boolean", short: "h", default: false },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (values.help) {
|
|
143
|
+
ctx.out.log(usage);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const port = Number(values.port ?? "3333");
|
|
148
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
149
|
+
ctx.out.fail(`invalid --port "${values.port}" (expected 1-65535)`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --base-url flag, else the resolved CLI config base URL (so it "just works"
|
|
153
|
+
// against the same instance the other commands target), unless that's the
|
|
154
|
+
// local default placeholder. Keep undefined for pure static preview.
|
|
155
|
+
const baseUrl =
|
|
156
|
+
typeof values["base-url"] === "string" ? values["base-url"] : undefined;
|
|
157
|
+
|
|
158
|
+
const distPath = resolveStudioDist(
|
|
159
|
+
typeof values.dist === "string" ? values.dist : positionals[0],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (!distPath) {
|
|
163
|
+
ctx.out.fail(
|
|
164
|
+
"could not find a built Studio (dist/). Build it with " +
|
|
165
|
+
"`pnpm --filter @hogsend/studio build`, or pass --dist <path>.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const cleanBase = baseUrl ? baseUrl.replace(/\/+$/, "") : undefined;
|
|
170
|
+
const index = indexHtml(distPath, cleanBase);
|
|
171
|
+
|
|
172
|
+
const server = createServer((req, res) => {
|
|
173
|
+
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
|
|
174
|
+
|
|
175
|
+
// The Studio bundle is built under base "/studio/", so all asset URLs are
|
|
176
|
+
// prefixed with /studio. Strip that prefix to map onto the dist root.
|
|
177
|
+
const rel = urlPath.replace(/^\/studio/, "");
|
|
178
|
+
if (rel === "" || rel === "/") {
|
|
179
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
180
|
+
res.end(index);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Resolve safely inside distPath (defend against path traversal).
|
|
185
|
+
const target = normalize(join(distPath, rel));
|
|
186
|
+
if (target !== distPath && !target.startsWith(distPath + sep)) {
|
|
187
|
+
res.writeHead(403);
|
|
188
|
+
res.end("Forbidden");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (existsSync(target) && statSync(target).isFile()) {
|
|
193
|
+
res.writeHead(200, { "content-type": mimeFor(target) });
|
|
194
|
+
createReadStream(target).pipe(res);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// SPA fallback: unknown paths serve index.html so client-side routes work.
|
|
199
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
200
|
+
res.end(index);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
204
|
+
server.once("error", reject);
|
|
205
|
+
server.listen(port, () => resolveListen());
|
|
206
|
+
}).catch((err: unknown) => {
|
|
207
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
208
|
+
ctx.out.fail(`could not start server on port ${port}: ${msg}`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const localUrl = `http://localhost:${port}/studio/`;
|
|
212
|
+
|
|
213
|
+
if (ctx.json) {
|
|
214
|
+
ctx.out.json({
|
|
215
|
+
url: localUrl,
|
|
216
|
+
port,
|
|
217
|
+
baseUrl: cleanBase ?? null,
|
|
218
|
+
dist: distPath,
|
|
219
|
+
});
|
|
220
|
+
// In json mode we still keep the server running (foreground). Agents that
|
|
221
|
+
// don't want a long-running process should not pass --json to `studio`.
|
|
222
|
+
} else {
|
|
223
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} studio`);
|
|
224
|
+
ctx.out.note(
|
|
225
|
+
[
|
|
226
|
+
`${color.green("●")} Studio serving at ${color.cyan(localUrl)}`,
|
|
227
|
+
cleanBase
|
|
228
|
+
? color.dim(`API instance: ${cleanBase}`)
|
|
229
|
+
: color.dim(
|
|
230
|
+
"No --base-url set (same-origin / static preview). The API " +
|
|
231
|
+
"calls will hit this local server and fail — pass --base-url " +
|
|
232
|
+
"<instance>, or open <instance>/studio directly.",
|
|
233
|
+
),
|
|
234
|
+
"",
|
|
235
|
+
color.dim("First load shows a create-admin screen if no admin exists."),
|
|
236
|
+
color.dim("Press Ctrl+C to stop."),
|
|
237
|
+
].join("\n"),
|
|
238
|
+
"Studio",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (values.open) {
|
|
243
|
+
openBrowser(localUrl);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Keep the process alive until interrupted.
|
|
247
|
+
await new Promise<void>((resolveForever) => {
|
|
248
|
+
const stop = () => {
|
|
249
|
+
server.close(() => resolveForever());
|
|
250
|
+
};
|
|
251
|
+
process.on("SIGINT", stop);
|
|
252
|
+
process.on("SIGTERM", stop);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const studioCommand: Command = {
|
|
257
|
+
name: "studio",
|
|
258
|
+
summary: "Serve the bundled Hogsend Studio admin SPA locally",
|
|
259
|
+
usage,
|
|
260
|
+
run,
|
|
261
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../lib/config.js";
|
|
2
|
+
import type { AdminClient } from "../lib/http.js";
|
|
3
|
+
import type { Output } from "../lib/output.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-invocation context handed to every command's `run()`. The router builds
|
|
7
|
+
* this once (after parsing global flags + resolving config) and passes it in,
|
|
8
|
+
* so command files never touch process.argv directly, never resolve config,
|
|
9
|
+
* and never construct an HTTP client themselves.
|
|
10
|
+
*/
|
|
11
|
+
export interface CommandContext {
|
|
12
|
+
/**
|
|
13
|
+
* The args AFTER the command token. e.g. for `hogsend journeys list --json`
|
|
14
|
+
* the router strips `journeys` and the global `--json`, leaving `["list"]`.
|
|
15
|
+
* Subcommand dispatch (list/get/...) is the command's own responsibility.
|
|
16
|
+
*/
|
|
17
|
+
argv: string[];
|
|
18
|
+
/** Base URL + admin key, already resolved via flags > env > .env. */
|
|
19
|
+
cfg: ResolvedConfig;
|
|
20
|
+
/** Pre-built admin HTTP client, bound to `cfg`. */
|
|
21
|
+
http: AdminClient;
|
|
22
|
+
/** Output sink — human (TTY clack) vs json, already mode-selected. */
|
|
23
|
+
out: Output;
|
|
24
|
+
/** True when the global `--json` flag was passed. Mirrors `out.isJson`. */
|
|
25
|
+
json: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The descriptor every command file implements. The router matches `name`
|
|
30
|
+
* against the leading argv token and dispatches to `run()`.
|
|
31
|
+
*/
|
|
32
|
+
export interface Command {
|
|
33
|
+
/** Command token, e.g. "journeys", "eject". */
|
|
34
|
+
name: string;
|
|
35
|
+
/** One-line help shown in the root command list. */
|
|
36
|
+
summary: string;
|
|
37
|
+
/** Multiline usage block shown on `hogsend <name> --help`. */
|
|
38
|
+
usage: string;
|
|
39
|
+
/** Execute the command. Throw to fail (router renders + exits 1). */
|
|
40
|
+
run(ctx: CommandContext): Promise<void>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { confirm } from "@clack/prompts";
|
|
6
|
+
import { color } from "../lib/output.js";
|
|
7
|
+
import { bail } from "../lib/prompt.js";
|
|
8
|
+
import {
|
|
9
|
+
copySkill,
|
|
10
|
+
installDir,
|
|
11
|
+
listBundledSkills,
|
|
12
|
+
writeSkillsStamp,
|
|
13
|
+
} from "../lib/skills.js";
|
|
14
|
+
import type { Command, CommandContext } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const usage = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
|
|
17
|
+
|
|
18
|
+
Upgrade a scaffolded Hogsend app in one step:
|
|
19
|
+
1. bump every @hogsend/* dependency to latest (or --to <version>), then
|
|
20
|
+
2. refresh the vendored Claude Code skills in ./.claude/skills to match.
|
|
21
|
+
|
|
22
|
+
Run this after a new engine release so your app AND the agent guidance move
|
|
23
|
+
together. Skills are version-stamped so \`hogsend doctor\` can warn when they
|
|
24
|
+
fall behind.
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--cwd <dir> Project root to upgrade (defaults to the current directory).
|
|
28
|
+
--pm <manager> Package manager (default: detected from the lockfile, else pnpm).
|
|
29
|
+
--to <version> Target version for @hogsend/* deps (default: latest).
|
|
30
|
+
--deps-only Bump dependencies only; don't touch skills.
|
|
31
|
+
--skills-only Refresh skills only; don't touch dependencies.
|
|
32
|
+
--yes, -y Skip the confirmation prompt. Implied by --json.
|
|
33
|
+
--json Run non-interactively and emit a single JSON result.
|
|
34
|
+
-h, --help Show this help.`;
|
|
35
|
+
|
|
36
|
+
type Pm = "pnpm" | "npm" | "yarn" | "bun";
|
|
37
|
+
const VALID_PMS: Pm[] = ["pnpm", "npm", "yarn", "bun"];
|
|
38
|
+
|
|
39
|
+
/** Detect the package manager from a lockfile, defaulting to pnpm. */
|
|
40
|
+
function detectPm(cwd: string): Pm {
|
|
41
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
42
|
+
if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
|
|
43
|
+
if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")))
|
|
44
|
+
return "bun";
|
|
45
|
+
if (existsSync(join(cwd, "package-lock.json"))) return "npm";
|
|
46
|
+
return "pnpm";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The @hogsend/* deps declared in the app's package.json. */
|
|
50
|
+
function hogsendDeps(cwd: string): string[] {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as {
|
|
52
|
+
dependencies?: Record<string, string>;
|
|
53
|
+
devDependencies?: Record<string, string>;
|
|
54
|
+
};
|
|
55
|
+
const all = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
56
|
+
return Object.keys(all)
|
|
57
|
+
.filter((n) => n.startsWith("@hogsend/"))
|
|
58
|
+
.sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build the install verb + args for the given pm (all but npm use `add`). */
|
|
62
|
+
function addArgs(pm: Pm, specs: string[]): string[] {
|
|
63
|
+
return [pm === "npm" ? "install" : "add", ...specs];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface StepResult {
|
|
67
|
+
step: string;
|
|
68
|
+
status: "ok" | "skipped" | "failed";
|
|
69
|
+
detail: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
73
|
+
const { values } = parseArgs({
|
|
74
|
+
args: ctx.argv,
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
options: {
|
|
77
|
+
cwd: { type: "string" },
|
|
78
|
+
pm: { type: "string" },
|
|
79
|
+
to: { type: "string" },
|
|
80
|
+
"deps-only": { type: "boolean", default: false },
|
|
81
|
+
"skills-only": { type: "boolean", default: false },
|
|
82
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
83
|
+
help: { type: "boolean", short: "h", default: false },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (values.help) {
|
|
88
|
+
ctx.out.log(usage);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (values["deps-only"] && values["skills-only"]) {
|
|
93
|
+
ctx.out.fail("--deps-only and --skills-only are mutually exclusive.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cwd = values.cwd ?? process.cwd();
|
|
97
|
+
if (!existsSync(join(cwd, "package.json"))) {
|
|
98
|
+
ctx.out.fail(
|
|
99
|
+
`no package.json in ${cwd} — run upgrade from a scaffolded Hogsend app (or pass --cwd).`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let pm: Pm;
|
|
104
|
+
if (values.pm !== undefined) {
|
|
105
|
+
if (!(VALID_PMS as string[]).includes(values.pm)) {
|
|
106
|
+
ctx.out.fail(
|
|
107
|
+
`invalid --pm "${values.pm}". Expected one of: ${VALID_PMS.join(", ")}.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
pm = values.pm as Pm;
|
|
111
|
+
} else {
|
|
112
|
+
pm = detectPm(cwd);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const target = values.to ?? "latest";
|
|
116
|
+
const doDeps = !values["skills-only"];
|
|
117
|
+
const doSkills = !values["deps-only"];
|
|
118
|
+
const deps = doDeps ? hogsendDeps(cwd) : [];
|
|
119
|
+
|
|
120
|
+
if (doDeps && deps.length === 0) {
|
|
121
|
+
ctx.out.fail(
|
|
122
|
+
`no @hogsend/* dependencies found in ${join(cwd, "package.json")}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const skipConfirm = ctx.json || values.yes;
|
|
127
|
+
if (!ctx.json) {
|
|
128
|
+
ctx.out.intro(
|
|
129
|
+
`${color.bgMagenta(color.black(" hogsend "))} ${color.dim("upgrade")}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (ctx.out.interactive && !skipConfirm) {
|
|
133
|
+
const plan = [
|
|
134
|
+
doDeps
|
|
135
|
+
? `bump ${deps.length} @hogsend/* dep(s) to ${target} (${pm})`
|
|
136
|
+
: null,
|
|
137
|
+
doSkills ? "refresh .claude/skills" : null,
|
|
138
|
+
]
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.join(" + ");
|
|
141
|
+
const proceed = bail(
|
|
142
|
+
await confirm({ message: `Upgrade ${color.cyan(cwd)}: ${plan}?` }),
|
|
143
|
+
);
|
|
144
|
+
if (!proceed) {
|
|
145
|
+
ctx.out.outro(color.dim("Nothing changed."));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const results: StepResult[] = [];
|
|
151
|
+
|
|
152
|
+
// 1. bump @hogsend/* deps via the package manager.
|
|
153
|
+
if (doDeps) {
|
|
154
|
+
const specs = deps.map((n) => `${n}@${target}`);
|
|
155
|
+
const dep = await ctx.out.step(
|
|
156
|
+
`Bumping @hogsend/* -> ${target} (${pm})`,
|
|
157
|
+
async () =>
|
|
158
|
+
spawnSync(pm, addArgs(pm, specs), {
|
|
159
|
+
cwd,
|
|
160
|
+
stdio: ctx.json ? "ignore" : "inherit",
|
|
161
|
+
shell: process.platform === "win32",
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
results.push({
|
|
165
|
+
step: "deps",
|
|
166
|
+
status: dep.status === 0 ? "ok" : "failed",
|
|
167
|
+
detail:
|
|
168
|
+
dep.status === 0
|
|
169
|
+
? `${deps.join(", ")} -> ${target}`
|
|
170
|
+
: `${pm} exited with code ${dep.status ?? "?"}`,
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
results.push({ step: "deps", status: "skipped", detail: "--skills-only" });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2. refresh vendored skills + re-stamp (only if deps didn't hard-fail).
|
|
177
|
+
const depsFailed = results.some(
|
|
178
|
+
(r) => r.step === "deps" && r.status === "failed",
|
|
179
|
+
);
|
|
180
|
+
if (!doSkills) {
|
|
181
|
+
results.push({
|
|
182
|
+
step: "skills",
|
|
183
|
+
status: "skipped",
|
|
184
|
+
detail: "--deps-only",
|
|
185
|
+
});
|
|
186
|
+
} else if (depsFailed) {
|
|
187
|
+
results.push({
|
|
188
|
+
step: "skills",
|
|
189
|
+
status: "skipped",
|
|
190
|
+
detail: "skipped — dependency bump failed; fix it then re-run",
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
const bundled = listBundledSkills(cwd);
|
|
194
|
+
const copied = bundled.map((s) => copySkill(s.name, cwd, true));
|
|
195
|
+
writeSkillsStamp(
|
|
196
|
+
cwd,
|
|
197
|
+
bundled.map((s) => s.name),
|
|
198
|
+
);
|
|
199
|
+
results.push({
|
|
200
|
+
step: "skills",
|
|
201
|
+
status: "ok",
|
|
202
|
+
detail: `refreshed ${copied.length} skill(s) -> ${installDir(cwd)}`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
207
|
+
const ok = failed.length === 0;
|
|
208
|
+
|
|
209
|
+
if (ctx.json) {
|
|
210
|
+
ctx.out.json({ ok, cwd, pm, target, steps: results });
|
|
211
|
+
if (!ok) process.exit(1);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
ctx.out.table(
|
|
216
|
+
results.map((r) => ({
|
|
217
|
+
step: r.step,
|
|
218
|
+
status:
|
|
219
|
+
r.status === "ok"
|
|
220
|
+
? color.green("ok")
|
|
221
|
+
: r.status === "skipped"
|
|
222
|
+
? color.dim("skipped")
|
|
223
|
+
: color.red("failed"),
|
|
224
|
+
detail: r.detail,
|
|
225
|
+
})),
|
|
226
|
+
["step", "status", "detail"],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (!ok) {
|
|
230
|
+
ctx.out.fail(
|
|
231
|
+
`${failed.length} step(s) failed — see the table above. Fix and re-run hogsend upgrade.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
ctx.out.outro(
|
|
236
|
+
`${color.green("Upgraded.")} ${color.dim("Engine + agent skills are on the latest line.")}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const upgradeCommand: Command = {
|
|
241
|
+
name: "upgrade",
|
|
242
|
+
summary: "Bump @hogsend/* deps to latest + refresh vendored skills",
|
|
243
|
+
usage,
|
|
244
|
+
run,
|
|
245
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
|
|
5
|
+
/** Resolved target for the admin HTTP client. */
|
|
6
|
+
export interface ResolvedConfig {
|
|
7
|
+
/** Base URL of the target instance, no trailing slash. */
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
/** Admin bearer token, if resolvable. `doctor`/health works without it. */
|
|
10
|
+
adminKey: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Global flags parsed off the front of any command's argv. */
|
|
14
|
+
export interface GlobalFlags {
|
|
15
|
+
url?: string;
|
|
16
|
+
adminKey?: string;
|
|
17
|
+
json: boolean;
|
|
18
|
+
help: boolean;
|
|
19
|
+
/** The remaining args after global flags are stripped. */
|
|
20
|
+
rest: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_BASE_URL = "http://localhost:3002";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse the global flags that every command honours (`--url`, `--admin-key`,
|
|
27
|
+
* `--json`, `-h`/`--help`) off an argv slice, returning the parsed values plus
|
|
28
|
+
* the leftover `rest` (positionals + unknown flags) for the command to handle.
|
|
29
|
+
*
|
|
30
|
+
* `strict: false` so command-specific flags (e.g. `--enabled`, `--limit`) pass
|
|
31
|
+
* through untouched in `rest` rather than throwing here.
|
|
32
|
+
*/
|
|
33
|
+
export function parseGlobalFlags(argv: string[]): GlobalFlags {
|
|
34
|
+
const { values, tokens } = parseArgs({
|
|
35
|
+
args: argv,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
strict: false,
|
|
38
|
+
tokens: true,
|
|
39
|
+
options: {
|
|
40
|
+
url: { type: "string" },
|
|
41
|
+
"admin-key": { type: "string" },
|
|
42
|
+
json: { type: "boolean", default: false },
|
|
43
|
+
help: { type: "boolean", short: "h", default: false },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Rebuild `rest` from the token stream, dropping only the global flags we
|
|
48
|
+
// own (and their values). Everything else — positionals and unknown option
|
|
49
|
+
// tokens — is preserved verbatim for the command's own parser.
|
|
50
|
+
const owned = new Set(["url", "admin-key", "json", "help", "h"]);
|
|
51
|
+
const rest: string[] = [];
|
|
52
|
+
for (const token of tokens) {
|
|
53
|
+
if (token.kind === "positional") {
|
|
54
|
+
rest.push(token.value);
|
|
55
|
+
} else if (token.kind === "option") {
|
|
56
|
+
if (owned.has(token.name)) continue;
|
|
57
|
+
rest.push(token.rawName);
|
|
58
|
+
if (token.value !== undefined && !token.inlineValue) {
|
|
59
|
+
rest.push(token.value);
|
|
60
|
+
} else if (token.inlineValue && token.value !== undefined) {
|
|
61
|
+
// already captured in rawName? no — rebuild as --name=value
|
|
62
|
+
rest[rest.length - 1] = `${token.rawName}=${token.value}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
url: typeof values.url === "string" ? values.url : undefined,
|
|
69
|
+
adminKey:
|
|
70
|
+
typeof values["admin-key"] === "string" ? values["admin-key"] : undefined,
|
|
71
|
+
json: values.json === true,
|
|
72
|
+
help: values.help === true,
|
|
73
|
+
rest,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Manually parse a `.env` file into a flat record. No dotenv dependency: a
|
|
79
|
+
* small, forgiving parser (KEY=VALUE per line, `#` comments, optional quotes,
|
|
80
|
+
* `export ` prefix tolerated). Never throws — a missing/unreadable file yields
|
|
81
|
+
* an empty record so config resolution stays robust in any cwd.
|
|
82
|
+
*/
|
|
83
|
+
export function loadDotEnv(
|
|
84
|
+
cwd: string = process.cwd(),
|
|
85
|
+
): Record<string, string> {
|
|
86
|
+
const out: Record<string, string> = {};
|
|
87
|
+
const file = join(cwd, ".env");
|
|
88
|
+
if (!existsSync(file)) return out;
|
|
89
|
+
let raw: string;
|
|
90
|
+
try {
|
|
91
|
+
raw = readFileSync(file, "utf8");
|
|
92
|
+
} catch {
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
96
|
+
const line = rawLine.trim();
|
|
97
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
98
|
+
const withoutExport = line.startsWith("export ")
|
|
99
|
+
? line.slice("export ".length)
|
|
100
|
+
: line;
|
|
101
|
+
const eq = withoutExport.indexOf("=");
|
|
102
|
+
if (eq === -1) continue;
|
|
103
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
104
|
+
if (key === "") continue;
|
|
105
|
+
let value = withoutExport.slice(eq + 1).trim();
|
|
106
|
+
if (
|
|
107
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
108
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
109
|
+
) {
|
|
110
|
+
value = value.slice(1, -1);
|
|
111
|
+
}
|
|
112
|
+
out[key] = value;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the target config with precedence flags > process.env > .env, falling
|
|
119
|
+
* back to the local-dev default base URL.
|
|
120
|
+
*
|
|
121
|
+
* baseUrl: --url > HOGSEND_API_URL (env) > HOGSEND_API_URL (.env) > localhost:3002
|
|
122
|
+
* adminKey: --admin-key > HOGSEND_ADMIN_KEY|ADMIN_API_KEY (env) > (.env equiv)
|
|
123
|
+
*/
|
|
124
|
+
export function resolveConfig(
|
|
125
|
+
flags: GlobalFlags,
|
|
126
|
+
cwd: string = process.cwd(),
|
|
127
|
+
): ResolvedConfig {
|
|
128
|
+
const dotenv = loadDotEnv(cwd);
|
|
129
|
+
|
|
130
|
+
const baseUrlRaw =
|
|
131
|
+
flags.url ??
|
|
132
|
+
process.env.HOGSEND_API_URL ??
|
|
133
|
+
dotenv.HOGSEND_API_URL ??
|
|
134
|
+
DEFAULT_BASE_URL;
|
|
135
|
+
|
|
136
|
+
const adminKey =
|
|
137
|
+
flags.adminKey ??
|
|
138
|
+
process.env.HOGSEND_ADMIN_KEY ??
|
|
139
|
+
process.env.ADMIN_API_KEY ??
|
|
140
|
+
dotenv.HOGSEND_ADMIN_KEY ??
|
|
141
|
+
dotenv.ADMIN_API_KEY;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
baseUrl: baseUrlRaw.replace(/\/+$/, ""),
|
|
145
|
+
adminKey: adminKey && adminKey.length > 0 ? adminKey : undefined,
|
|
146
|
+
};
|
|
147
|
+
}
|