@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
package/dist/bin.js
CHANGED
|
@@ -1,15 +1,723 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/bin.ts
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { createRequire as createRequire3 } from "module";
|
|
5
|
+
|
|
6
|
+
// src/commands/contacts.ts
|
|
7
7
|
import { parseArgs } from "util";
|
|
8
8
|
|
|
9
|
+
// src/lib/http.ts
|
|
10
|
+
function isHttpError(value) {
|
|
11
|
+
return value instanceof Error && "status" in value;
|
|
12
|
+
}
|
|
13
|
+
function makeHttpError(message, status, body) {
|
|
14
|
+
const err = new Error(message);
|
|
15
|
+
err.name = "HttpError";
|
|
16
|
+
err.status = status;
|
|
17
|
+
err.body = body;
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
function buildUrl(baseUrl, path, query) {
|
|
21
|
+
const url = new URL(path.startsWith("/") ? path : `/${path}`, `${baseUrl}/`);
|
|
22
|
+
if (query) {
|
|
23
|
+
for (const [key, value] of Object.entries(query)) {
|
|
24
|
+
if (value === void 0) continue;
|
|
25
|
+
url.searchParams.set(key, String(value));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return url.toString();
|
|
29
|
+
}
|
|
30
|
+
function bodyMessage(status, body) {
|
|
31
|
+
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") {
|
|
32
|
+
return `${status}: ${body.error}`;
|
|
33
|
+
}
|
|
34
|
+
return `request failed with status ${status}`;
|
|
35
|
+
}
|
|
36
|
+
function createAdminClient(cfg) {
|
|
37
|
+
async function request(method, path, opts) {
|
|
38
|
+
if (opts.auth && !cfg.adminKey) {
|
|
39
|
+
throw makeHttpError(
|
|
40
|
+
"no admin key configured \u2014 pass --admin-key, or set HOGSEND_ADMIN_KEY / ADMIN_API_KEY",
|
|
41
|
+
0,
|
|
42
|
+
void 0
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const headers = { Accept: "application/json" };
|
|
46
|
+
if (opts.auth && cfg.adminKey) {
|
|
47
|
+
headers.Authorization = `Bearer ${cfg.adminKey}`;
|
|
48
|
+
}
|
|
49
|
+
if (opts.body !== void 0) {
|
|
50
|
+
headers["Content-Type"] = "application/json";
|
|
51
|
+
}
|
|
52
|
+
const url = buildUrl(cfg.baseUrl, path, opts.query);
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers,
|
|
58
|
+
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
|
|
59
|
+
});
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
62
|
+
throw makeHttpError(`cannot reach ${cfg.baseUrl} (${msg})`, 0, void 0);
|
|
63
|
+
}
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let parsed;
|
|
66
|
+
if (text.length > 0) {
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(text);
|
|
69
|
+
} catch {
|
|
70
|
+
parsed = text;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw makeHttpError(bodyMessage(res.status, parsed), res.status, parsed);
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
cfg,
|
|
80
|
+
get: (path, query, extras) => request("GET", path, { query, auth: extras?.auth ?? true }),
|
|
81
|
+
patch: (path, body) => request("PATCH", path, { body, auth: true }),
|
|
82
|
+
post: (path, body) => request("POST", path, { body, auth: true })
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/lib/output.ts
|
|
87
|
+
import {
|
|
88
|
+
cancel,
|
|
89
|
+
intro as clackIntro,
|
|
90
|
+
note as clackNote,
|
|
91
|
+
outro as clackOutro,
|
|
92
|
+
spinner
|
|
93
|
+
} from "@clack/prompts";
|
|
94
|
+
import color from "picocolors";
|
|
95
|
+
function renderTable(rows, columns) {
|
|
96
|
+
if (rows.length === 0) return color.dim("(no rows)");
|
|
97
|
+
const cols = columns ?? Array.from(
|
|
98
|
+
rows.reduce((set, row) => {
|
|
99
|
+
for (const key of Object.keys(row)) set.add(key);
|
|
100
|
+
return set;
|
|
101
|
+
}, /* @__PURE__ */ new Set())
|
|
102
|
+
);
|
|
103
|
+
const cell = (value) => {
|
|
104
|
+
if (value === null || value === void 0) return "";
|
|
105
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
106
|
+
return String(value);
|
|
107
|
+
};
|
|
108
|
+
const widths = cols.map(
|
|
109
|
+
(c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length))
|
|
110
|
+
);
|
|
111
|
+
const pad = (text, width) => text + " ".repeat(width - text.length);
|
|
112
|
+
const header = cols.map((c, i) => color.bold(pad(c, widths[i] ?? 0))).join(" ");
|
|
113
|
+
const sep4 = cols.map((_, i) => "-".repeat(widths[i] ?? 0)).join(" ");
|
|
114
|
+
const body = rows.map((r) => cols.map((c, i) => pad(cell(r[c]), widths[i] ?? 0)).join(" ")).join("\n");
|
|
115
|
+
return `${header}
|
|
116
|
+
${color.dim(sep4)}
|
|
117
|
+
${body}`;
|
|
118
|
+
}
|
|
119
|
+
function renderKv(obj) {
|
|
120
|
+
const keys = Object.keys(obj);
|
|
121
|
+
if (keys.length === 0) return color.dim("(empty)");
|
|
122
|
+
const width = Math.max(...keys.map((k) => k.length));
|
|
123
|
+
return keys.map((k) => {
|
|
124
|
+
const v = obj[k];
|
|
125
|
+
const str = v === null || v === void 0 ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
126
|
+
return `${color.dim(`${k}:`.padEnd(width + 1))} ${str}`;
|
|
127
|
+
}).join("\n");
|
|
128
|
+
}
|
|
129
|
+
function createOutput(opts) {
|
|
130
|
+
const isJson = opts.json;
|
|
131
|
+
const interactive = !isJson && Boolean(process.stdout.isTTY);
|
|
132
|
+
return {
|
|
133
|
+
interactive,
|
|
134
|
+
isJson,
|
|
135
|
+
intro(title) {
|
|
136
|
+
if (!interactive) return;
|
|
137
|
+
clackIntro(title);
|
|
138
|
+
},
|
|
139
|
+
async step(label, fn) {
|
|
140
|
+
if (interactive) {
|
|
141
|
+
const s = spinner();
|
|
142
|
+
s.start(label);
|
|
143
|
+
try {
|
|
144
|
+
const result = await fn();
|
|
145
|
+
s.stop(`${color.green("\u2713")} ${label}`);
|
|
146
|
+
return result;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
s.stop(`${color.red("\u2717")} ${label}`);
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!isJson) console.log(` ${label} ...`);
|
|
153
|
+
return fn();
|
|
154
|
+
},
|
|
155
|
+
note(body, title) {
|
|
156
|
+
if (isJson) return;
|
|
157
|
+
if (interactive) {
|
|
158
|
+
clackNote(body, title);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (title) console.log(`
|
|
162
|
+
${title}`);
|
|
163
|
+
console.log(body);
|
|
164
|
+
},
|
|
165
|
+
table(rows, columns) {
|
|
166
|
+
if (isJson) return;
|
|
167
|
+
console.log(renderTable(rows, columns));
|
|
168
|
+
},
|
|
169
|
+
kv(obj, title) {
|
|
170
|
+
if (isJson) return;
|
|
171
|
+
if (title) console.log(color.bold(title));
|
|
172
|
+
console.log(renderKv(obj));
|
|
173
|
+
},
|
|
174
|
+
log(msg) {
|
|
175
|
+
if (isJson) return;
|
|
176
|
+
console.log(msg);
|
|
177
|
+
},
|
|
178
|
+
json(payload) {
|
|
179
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
180
|
+
`);
|
|
181
|
+
},
|
|
182
|
+
outro(msg) {
|
|
183
|
+
if (!interactive) return;
|
|
184
|
+
clackOutro(msg);
|
|
185
|
+
},
|
|
186
|
+
fail(message) {
|
|
187
|
+
if (isJson) {
|
|
188
|
+
process.stdout.write(`${JSON.stringify({ error: message })}
|
|
189
|
+
`);
|
|
190
|
+
} else if (interactive) {
|
|
191
|
+
cancel(message);
|
|
192
|
+
} else {
|
|
193
|
+
process.stderr.write(`${color.red("error")} ${message}
|
|
194
|
+
`);
|
|
195
|
+
}
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/commands/contacts.ts
|
|
202
|
+
var usage = `hogsend contacts <subcommand> [options]
|
|
203
|
+
|
|
204
|
+
Inspect contacts via the running app's admin API (/v1/admin/contacts).
|
|
205
|
+
|
|
206
|
+
Subcommands:
|
|
207
|
+
list List contacts (newest activity first).
|
|
208
|
+
get <id> Get one contact (by id or externalId) + preferences.
|
|
209
|
+
timeline <id> Merged event/email/journey activity for a contact.
|
|
210
|
+
|
|
211
|
+
list options:
|
|
212
|
+
--search <q> Filter by email/externalId substring.
|
|
213
|
+
--limit <n> Page size (1-100, default 50).
|
|
214
|
+
--offset <n> Page offset (default 0).
|
|
215
|
+
|
|
216
|
+
timeline options:
|
|
217
|
+
--type <t> Restrict to one of: event | journey | email.
|
|
218
|
+
--limit <n> Page size (1-100, default 50).
|
|
219
|
+
--offset <n> Page offset (default 0).
|
|
220
|
+
|
|
221
|
+
Global options (handled by the router): --url, --admin-key, --json, -h/--help.
|
|
222
|
+
|
|
223
|
+
Examples:
|
|
224
|
+
hogsend contacts list --search acme@ --json
|
|
225
|
+
hogsend contacts get user_123
|
|
226
|
+
hogsend contacts timeline user_123 --type email --json`;
|
|
227
|
+
var badge = `${color.bgMagenta(color.black(" hogsend "))} contacts`;
|
|
228
|
+
async function fetchOrFail(ctx, label, fn) {
|
|
229
|
+
try {
|
|
230
|
+
return await ctx.out.step(label, fn);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (isHttpError(err)) {
|
|
233
|
+
if (err.status === 404) {
|
|
234
|
+
ctx.out.fail(err.message || "contact not found");
|
|
235
|
+
}
|
|
236
|
+
ctx.out.fail(err.message);
|
|
237
|
+
}
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function runList(ctx, argv) {
|
|
242
|
+
const { values } = parseArgs({
|
|
243
|
+
args: argv,
|
|
244
|
+
allowPositionals: true,
|
|
245
|
+
options: {
|
|
246
|
+
search: { type: "string" },
|
|
247
|
+
limit: { type: "string" },
|
|
248
|
+
offset: { type: "string" },
|
|
249
|
+
help: { type: "boolean", short: "h", default: false }
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
if (values.help) {
|
|
253
|
+
ctx.out.log(usage);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const query = {
|
|
257
|
+
search: values.search,
|
|
258
|
+
limit: values.limit,
|
|
259
|
+
offset: values.offset
|
|
260
|
+
};
|
|
261
|
+
if (!ctx.json) ctx.out.intro(`${badge} list`);
|
|
262
|
+
const res = await fetchOrFail(
|
|
263
|
+
ctx,
|
|
264
|
+
"Fetching contacts",
|
|
265
|
+
() => ctx.http.get("/v1/admin/contacts", query)
|
|
266
|
+
);
|
|
267
|
+
if (ctx.json) {
|
|
268
|
+
ctx.out.json(res);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
ctx.out.table(
|
|
272
|
+
res.contacts.map((cnt) => ({
|
|
273
|
+
id: cnt.id,
|
|
274
|
+
externalId: cnt.externalId,
|
|
275
|
+
email: cnt.email ?? color.dim("(none)"),
|
|
276
|
+
lastSeenAt: cnt.lastSeenAt
|
|
277
|
+
})),
|
|
278
|
+
["id", "externalId", "email", "lastSeenAt"]
|
|
279
|
+
);
|
|
280
|
+
ctx.out.outro(
|
|
281
|
+
`${res.contacts.length} of ${res.total} contact(s) \u2014 offset ${res.offset}, limit ${res.limit}`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
async function runGet(ctx, argv) {
|
|
285
|
+
const { values, positionals } = parseArgs({
|
|
286
|
+
args: argv,
|
|
287
|
+
allowPositionals: true,
|
|
288
|
+
options: {
|
|
289
|
+
help: { type: "boolean", short: "h", default: false }
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
if (values.help) {
|
|
293
|
+
ctx.out.log(usage);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const id = positionals[1];
|
|
297
|
+
if (!id) {
|
|
298
|
+
ctx.out.fail(
|
|
299
|
+
"contacts get requires an id, e.g. hogsend contacts get user_123"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (!ctx.json) ctx.out.intro(`${badge} get`);
|
|
303
|
+
const res = await fetchOrFail(
|
|
304
|
+
ctx,
|
|
305
|
+
"Fetching contact",
|
|
306
|
+
() => ctx.http.get(`/v1/admin/contacts/${encodeURIComponent(id)}`)
|
|
307
|
+
);
|
|
308
|
+
if (ctx.json) {
|
|
309
|
+
ctx.out.json(res);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const { contact, preferences } = res;
|
|
313
|
+
ctx.out.kv(
|
|
314
|
+
{
|
|
315
|
+
id: contact.id,
|
|
316
|
+
externalId: contact.externalId,
|
|
317
|
+
email: contact.email ?? color.dim("(none)"),
|
|
318
|
+
firstSeenAt: contact.firstSeenAt,
|
|
319
|
+
lastSeenAt: contact.lastSeenAt,
|
|
320
|
+
properties: contact.properties
|
|
321
|
+
},
|
|
322
|
+
"Contact"
|
|
323
|
+
);
|
|
324
|
+
if (preferences) {
|
|
325
|
+
ctx.out.kv(
|
|
326
|
+
{
|
|
327
|
+
unsubscribedAll: preferences.unsubscribedAll,
|
|
328
|
+
suppressed: preferences.suppressed,
|
|
329
|
+
bounceCount: preferences.bounceCount,
|
|
330
|
+
categories: preferences.categories
|
|
331
|
+
},
|
|
332
|
+
"Preferences"
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
ctx.out.log(color.dim("No email preferences on record."));
|
|
336
|
+
}
|
|
337
|
+
ctx.out.outro(`Contact ${color.cyan(contact.externalId)}`);
|
|
338
|
+
}
|
|
339
|
+
async function runTimeline(ctx, argv) {
|
|
340
|
+
const { values, positionals } = parseArgs({
|
|
341
|
+
args: argv,
|
|
342
|
+
allowPositionals: true,
|
|
343
|
+
options: {
|
|
344
|
+
type: { type: "string" },
|
|
345
|
+
limit: { type: "string" },
|
|
346
|
+
offset: { type: "string" },
|
|
347
|
+
help: { type: "boolean", short: "h", default: false }
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
if (values.help) {
|
|
351
|
+
ctx.out.log(usage);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const id = positionals[1];
|
|
355
|
+
if (!id) {
|
|
356
|
+
ctx.out.fail(
|
|
357
|
+
"contacts timeline requires an id, e.g. hogsend contacts timeline user_123"
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (values.type && !["event", "journey", "email"].includes(values.type)) {
|
|
361
|
+
ctx.out.fail("--type must be one of: event, journey, email");
|
|
362
|
+
}
|
|
363
|
+
const query = {
|
|
364
|
+
type: values.type,
|
|
365
|
+
limit: values.limit,
|
|
366
|
+
offset: values.offset
|
|
367
|
+
};
|
|
368
|
+
if (!ctx.json) ctx.out.intro(`${badge} timeline`);
|
|
369
|
+
const res = await fetchOrFail(
|
|
370
|
+
ctx,
|
|
371
|
+
"Fetching timeline",
|
|
372
|
+
() => ctx.http.get(
|
|
373
|
+
`/v1/admin/contacts/${encodeURIComponent(id)}/timeline`,
|
|
374
|
+
query
|
|
375
|
+
)
|
|
376
|
+
);
|
|
377
|
+
if (ctx.json) {
|
|
378
|
+
ctx.out.json(res);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
ctx.out.table(
|
|
382
|
+
res.timeline.map((entry) => ({
|
|
383
|
+
timestamp: entry.timestamp,
|
|
384
|
+
type: entry.type,
|
|
385
|
+
summary: summarizeTimelineEntry(entry)
|
|
386
|
+
})),
|
|
387
|
+
["timestamp", "type", "summary"]
|
|
388
|
+
);
|
|
389
|
+
ctx.out.outro(
|
|
390
|
+
`${res.timeline.length} of ${res.total} entry(s) \u2014 offset ${res.offset}, limit ${res.limit}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
function summarizeTimelineEntry(entry) {
|
|
394
|
+
const d = entry.data;
|
|
395
|
+
if (entry.type === "event") {
|
|
396
|
+
return String(d.event ?? "");
|
|
397
|
+
}
|
|
398
|
+
if (entry.type === "journey") {
|
|
399
|
+
return `${String(d.journeyId ?? "")} (${String(d.status ?? "")})`;
|
|
400
|
+
}
|
|
401
|
+
const subject = d.subject ? String(d.subject) : String(d.templateKey ?? "");
|
|
402
|
+
return `${subject} [${String(d.status ?? "")}]`;
|
|
403
|
+
}
|
|
404
|
+
async function run(ctx) {
|
|
405
|
+
const sub = ctx.argv[0];
|
|
406
|
+
switch (sub) {
|
|
407
|
+
case "list":
|
|
408
|
+
return runList(ctx, ctx.argv);
|
|
409
|
+
case "get":
|
|
410
|
+
return runGet(ctx, ctx.argv);
|
|
411
|
+
case "timeline":
|
|
412
|
+
return runTimeline(ctx, ctx.argv);
|
|
413
|
+
case void 0:
|
|
414
|
+
ctx.out.fail(
|
|
415
|
+
"contacts requires a subcommand: list, get, or timeline (see hogsend contacts --help)"
|
|
416
|
+
);
|
|
417
|
+
break;
|
|
418
|
+
default:
|
|
419
|
+
ctx.out.fail(
|
|
420
|
+
`unknown contacts subcommand "${sub}" \u2014 expected list, get, or timeline`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
var contactsCommand = {
|
|
425
|
+
name: "contacts",
|
|
426
|
+
summary: "List, inspect, and trace contact activity",
|
|
427
|
+
usage,
|
|
428
|
+
run
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// src/commands/doctor.ts
|
|
432
|
+
import { parseArgs as parseArgs2 } from "util";
|
|
433
|
+
|
|
434
|
+
// src/lib/skills.ts
|
|
435
|
+
import {
|
|
436
|
+
cpSync,
|
|
437
|
+
existsSync,
|
|
438
|
+
mkdirSync,
|
|
439
|
+
readdirSync,
|
|
440
|
+
readFileSync,
|
|
441
|
+
statSync,
|
|
442
|
+
writeFileSync
|
|
443
|
+
} from "fs";
|
|
444
|
+
import { createRequire } from "module";
|
|
445
|
+
import { join } from "path";
|
|
446
|
+
import { fileURLToPath } from "url";
|
|
447
|
+
function bundledSkillsDir() {
|
|
448
|
+
return fileURLToPath(new URL("../skills", import.meta.url));
|
|
449
|
+
}
|
|
450
|
+
function installDir(cwd) {
|
|
451
|
+
return join(cwd, ".claude", "skills");
|
|
452
|
+
}
|
|
453
|
+
function stampPath(cwd) {
|
|
454
|
+
return join(cwd, ".claude", ".hogsend-skills.json");
|
|
455
|
+
}
|
|
456
|
+
function cliVersion() {
|
|
457
|
+
try {
|
|
458
|
+
const require2 = createRequire(import.meta.url);
|
|
459
|
+
const pkg = require2("../package.json");
|
|
460
|
+
return pkg.version ?? "0.0.0";
|
|
461
|
+
} catch {
|
|
462
|
+
return "0.0.0";
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function readFileSyncSafe(path) {
|
|
466
|
+
try {
|
|
467
|
+
return readFileSync(path, "utf8");
|
|
468
|
+
} catch {
|
|
469
|
+
return "";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function readFrontmatterField(skillDir, field) {
|
|
473
|
+
const skillFile = join(skillDir, "SKILL.md");
|
|
474
|
+
if (!existsSync(skillFile)) return "";
|
|
475
|
+
const raw = readFileSyncSafe(skillFile);
|
|
476
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
477
|
+
if (!fmMatch) return "";
|
|
478
|
+
const block = fmMatch[1] ?? "";
|
|
479
|
+
for (const line of block.split("\n")) {
|
|
480
|
+
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
481
|
+
if (m && m[1] === field) {
|
|
482
|
+
return (m[2] ?? "").replace(/^["']|["']$/g, "").trim();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return "";
|
|
486
|
+
}
|
|
487
|
+
function listBundledSkills(cwd) {
|
|
488
|
+
const dir = bundledSkillsDir();
|
|
489
|
+
if (!existsSync(dir)) return [];
|
|
490
|
+
const target = installDir(cwd);
|
|
491
|
+
const entries = readdirSync(dir).filter((name) => {
|
|
492
|
+
const full = join(dir, name);
|
|
493
|
+
return statSync(full).isDirectory() && existsSync(join(full, "SKILL.md"));
|
|
494
|
+
});
|
|
495
|
+
return entries.sort().map((name) => ({
|
|
496
|
+
name,
|
|
497
|
+
description: readFrontmatterField(join(dir, name), "description"),
|
|
498
|
+
installed: existsSync(join(target, name))
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
function copySkill(name, cwd, force) {
|
|
502
|
+
const src = join(bundledSkillsDir(), name);
|
|
503
|
+
const dest = join(installDir(cwd), name);
|
|
504
|
+
const exists = existsSync(dest);
|
|
505
|
+
if (exists && !force) {
|
|
506
|
+
return { name, installed: false, skipped: true, path: dest };
|
|
507
|
+
}
|
|
508
|
+
mkdirSync(installDir(cwd), { recursive: true });
|
|
509
|
+
cpSync(src, dest, { recursive: true, force: true });
|
|
510
|
+
return { name, installed: true, skipped: false, path: dest };
|
|
511
|
+
}
|
|
512
|
+
function writeSkillsStamp(cwd, skills) {
|
|
513
|
+
const stamp = {
|
|
514
|
+
cliVersion: cliVersion(),
|
|
515
|
+
skills: [...skills].sort(),
|
|
516
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
517
|
+
};
|
|
518
|
+
mkdirSync(join(cwd, ".claude"), { recursive: true });
|
|
519
|
+
writeFileSync(stampPath(cwd), `${JSON.stringify(stamp, null, 2)}
|
|
520
|
+
`);
|
|
521
|
+
}
|
|
522
|
+
function readSkillsStamp(cwd) {
|
|
523
|
+
try {
|
|
524
|
+
const parsed = JSON.parse(readFileSync(stampPath(cwd), "utf8"));
|
|
525
|
+
return parsed && typeof parsed.cliVersion === "string" ? parsed : null;
|
|
526
|
+
} catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function compareVersions(a, b) {
|
|
531
|
+
const parse = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
532
|
+
const pa = parse(a);
|
|
533
|
+
const pb = parse(b);
|
|
534
|
+
for (let i = 0; i < 3; i++) {
|
|
535
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
536
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
537
|
+
}
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
function skillsStaleness(cwd) {
|
|
541
|
+
const stamp = readSkillsStamp(cwd);
|
|
542
|
+
if (!stamp) return null;
|
|
543
|
+
const current = cliVersion();
|
|
544
|
+
return {
|
|
545
|
+
stale: compareVersions(stamp.cliVersion, current) < 0,
|
|
546
|
+
installed: stamp.cliVersion,
|
|
547
|
+
current
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/commands/doctor.ts
|
|
552
|
+
function skillsNudge(ctx) {
|
|
553
|
+
const verdict = skillsStaleness(process.cwd());
|
|
554
|
+
if (!verdict?.stale || ctx.json) return;
|
|
555
|
+
ctx.out.note(
|
|
556
|
+
[
|
|
557
|
+
`Vendored Claude skills are from v${verdict.installed}; this CLI is v${verdict.current}.`,
|
|
558
|
+
"",
|
|
559
|
+
`Refresh: ${color.cyan("hogsend upgrade")} ${color.dim("(deps + skills)")} or ${color.cyan("hogsend skills add --all --force")}.`
|
|
560
|
+
].join("\n"),
|
|
561
|
+
"Skills out of date"
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
var usage2 = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
|
|
565
|
+
|
|
566
|
+
Probe a running Hogsend instance via GET /v1/health and report its health:
|
|
567
|
+
component status (database, redis), two-track schema state (engine + client),
|
|
568
|
+
and an overall verdict.
|
|
569
|
+
|
|
570
|
+
The health route is unauthenticated, so doctor works without an admin key.
|
|
571
|
+
|
|
572
|
+
Verdict:
|
|
573
|
+
ok service healthy, all components up, schema in sync
|
|
574
|
+
degraded reachable but a component (database/redis) is down
|
|
575
|
+
migration_pending reachable but a schema track is behind (pending migrations)
|
|
576
|
+
unreachable the instance could not be reached at all
|
|
577
|
+
|
|
578
|
+
Exit code: 0 when ok, 1 when unreachable / degraded / migration_pending.
|
|
579
|
+
|
|
580
|
+
Options:
|
|
581
|
+
--url <baseUrl> Target instance (default HOGSEND_API_URL / .env / :3002).
|
|
582
|
+
--admin-key <key> Unused by doctor (health is unauthenticated).
|
|
583
|
+
--json Emit machine-readable JSON only.
|
|
584
|
+
-h, --help Show this help.`;
|
|
585
|
+
function toVerdict(status) {
|
|
586
|
+
switch (status) {
|
|
587
|
+
case "healthy":
|
|
588
|
+
return "ok";
|
|
589
|
+
case "degraded":
|
|
590
|
+
return "degraded";
|
|
591
|
+
case "migration_pending":
|
|
592
|
+
return "migration_pending";
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function componentSymbol(status) {
|
|
596
|
+
return status === "up" ? color.green("up") : color.red("down");
|
|
597
|
+
}
|
|
598
|
+
function trackLine(name, track) {
|
|
599
|
+
const sync = track.inSync ? color.green("in sync") : color.yellow(
|
|
600
|
+
`behind (${track.pending.length} pending: ${track.pending.length > 0 ? track.pending.join(", ") : "n/a"})`
|
|
601
|
+
);
|
|
602
|
+
const applied = track.applied ?? color.dim("none");
|
|
603
|
+
const required = track.required ?? color.dim("none");
|
|
604
|
+
return `${color.bold(name.padEnd(7))} applied ${applied} -> required ${required} ${sync}`;
|
|
605
|
+
}
|
|
606
|
+
async function run2(ctx) {
|
|
607
|
+
const { values } = parseArgs2({
|
|
608
|
+
args: ctx.argv,
|
|
609
|
+
allowPositionals: true,
|
|
610
|
+
options: {
|
|
611
|
+
help: { type: "boolean", short: "h", default: false }
|
|
612
|
+
},
|
|
613
|
+
// doctor takes no extra flags of its own; tolerate stray tokens.
|
|
614
|
+
strict: false
|
|
615
|
+
});
|
|
616
|
+
if (values.help) {
|
|
617
|
+
ctx.out.log(usage2);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const { baseUrl } = ctx.http.cfg;
|
|
621
|
+
let health = null;
|
|
622
|
+
let reachError = null;
|
|
623
|
+
try {
|
|
624
|
+
health = await ctx.out.step(
|
|
625
|
+
`GET ${baseUrl}/v1/health`,
|
|
626
|
+
() => ctx.http.get("/v1/health", void 0, { auth: false })
|
|
627
|
+
);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
if (isHttpError(error) && error.status === 0) {
|
|
630
|
+
reachError = error.message;
|
|
631
|
+
} else if (isHttpError(error)) {
|
|
632
|
+
reachError = error.message;
|
|
633
|
+
} else {
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (!health) {
|
|
638
|
+
const verdict2 = "unreachable";
|
|
639
|
+
if (ctx.json) {
|
|
640
|
+
ctx.out.json({
|
|
641
|
+
ok: false,
|
|
642
|
+
verdict: verdict2,
|
|
643
|
+
baseUrl,
|
|
644
|
+
error: reachError ?? "unreachable"
|
|
645
|
+
});
|
|
646
|
+
process.exit(1);
|
|
647
|
+
}
|
|
648
|
+
ctx.out.note(
|
|
649
|
+
[
|
|
650
|
+
`${color.red("\u25CF")} ${color.bold("unreachable")}`,
|
|
651
|
+
"",
|
|
652
|
+
reachError ?? `could not reach ${baseUrl}`,
|
|
653
|
+
"",
|
|
654
|
+
color.dim("Is the instance running? Check --url / HOGSEND_API_URL.")
|
|
655
|
+
].join("\n"),
|
|
656
|
+
"Doctor"
|
|
657
|
+
);
|
|
658
|
+
ctx.out.outro(color.red("doctor: unreachable"));
|
|
659
|
+
process.exit(1);
|
|
660
|
+
}
|
|
661
|
+
const verdict = toVerdict(health.status);
|
|
662
|
+
const ok = verdict === "ok";
|
|
663
|
+
if (ctx.json) {
|
|
664
|
+
ctx.out.json({
|
|
665
|
+
ok,
|
|
666
|
+
verdict,
|
|
667
|
+
baseUrl,
|
|
668
|
+
version: health.version,
|
|
669
|
+
uptime: health.uptime,
|
|
670
|
+
timestamp: health.timestamp,
|
|
671
|
+
components: health.components,
|
|
672
|
+
schema: health.schema,
|
|
673
|
+
skills: skillsStaleness(process.cwd()) ?? void 0
|
|
674
|
+
});
|
|
675
|
+
if (!ok) process.exit(1);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const badge3 = `${color.bgMagenta(color.black(" hogsend "))} doctor`;
|
|
679
|
+
ctx.out.intro(badge3);
|
|
680
|
+
const verdictColor = verdict === "ok" ? color.green : verdict === "degraded" ? color.red : color.yellow;
|
|
681
|
+
const lines = [
|
|
682
|
+
`${verdictColor("\u25CF")} ${color.bold(verdict)}`,
|
|
683
|
+
color.dim(
|
|
684
|
+
`${baseUrl} v${health.version} up ${Math.round(health.uptime)}s`
|
|
685
|
+
),
|
|
686
|
+
"",
|
|
687
|
+
color.bold("Components"),
|
|
688
|
+
` database ${componentSymbol(health.components.database.status)}${health.components.database.latencyMs !== void 0 ? color.dim(` ${health.components.database.latencyMs}ms`) : ""}`,
|
|
689
|
+
` redis ${componentSymbol(health.components.redis.status)}${health.components.redis.latencyMs !== void 0 ? color.dim(` ${health.components.redis.latencyMs}ms`) : ""}`,
|
|
690
|
+
"",
|
|
691
|
+
color.bold("Schema"),
|
|
692
|
+
` ${trackLine("engine", health.schema.engine)}`,
|
|
693
|
+
` ${trackLine("client", health.schema.client)}`
|
|
694
|
+
];
|
|
695
|
+
ctx.out.note(lines.join("\n"), "Doctor");
|
|
696
|
+
skillsNudge(ctx);
|
|
697
|
+
if (ok) {
|
|
698
|
+
ctx.out.outro(color.green("doctor: ok"));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
ctx.out.outro(verdictColor(`doctor: ${verdict}`));
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
var doctorCommand = {
|
|
705
|
+
name: "doctor",
|
|
706
|
+
summary: "Probe a running instance's health (GET /v1/health)",
|
|
707
|
+
usage: usage2,
|
|
708
|
+
run: run2
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// src/commands/eject.ts
|
|
712
|
+
import { existsSync as existsSync3, realpathSync } from "fs";
|
|
713
|
+
import { createRequire as createRequire2 } from "module";
|
|
714
|
+
import { dirname, join as join3, sep as sep2 } from "path";
|
|
715
|
+
import { parseArgs as parseArgs3 } from "util";
|
|
716
|
+
|
|
9
717
|
// src/eject.ts
|
|
10
|
-
import { existsSync } from "fs";
|
|
718
|
+
import { existsSync as existsSync2 } from "fs";
|
|
11
719
|
import { cp, readFile, rm, stat, writeFile } from "fs/promises";
|
|
12
|
-
import { basename, join, relative, sep } from "path";
|
|
720
|
+
import { basename, join as join2, relative, sep } from "path";
|
|
13
721
|
var EjectError = class extends Error {
|
|
14
722
|
constructor(message) {
|
|
15
723
|
super(message);
|
|
@@ -34,8 +742,8 @@ async function writePackageJson(file, value) {
|
|
|
34
742
|
async function eject(opts) {
|
|
35
743
|
const { pkg, consumerRoot, sourceDir, force = false } = opts;
|
|
36
744
|
const vendorName = basename(pkg);
|
|
37
|
-
const vendorPath =
|
|
38
|
-
const consumerPkgPath =
|
|
745
|
+
const vendorPath = join2(consumerRoot, "vendor", vendorName);
|
|
746
|
+
const consumerPkgPath = join2(consumerRoot, "package.json");
|
|
39
747
|
const consumerPkg = await readPackageJson(consumerPkgPath);
|
|
40
748
|
let depMap;
|
|
41
749
|
let depSpecBefore;
|
|
@@ -51,7 +759,7 @@ async function eject(opts) {
|
|
|
51
759
|
`${pkg} is not a dependency of the consumer package.json`
|
|
52
760
|
);
|
|
53
761
|
}
|
|
54
|
-
if (
|
|
762
|
+
if (existsSync2(vendorPath)) {
|
|
55
763
|
if (!force) {
|
|
56
764
|
throw new EjectError(
|
|
57
765
|
`vendor/${vendorName} already exists; pass --force to overwrite`
|
|
@@ -79,7 +787,7 @@ async function eject(opts) {
|
|
|
79
787
|
}
|
|
80
788
|
});
|
|
81
789
|
copiedFiles = await countFiles(vendorPath);
|
|
82
|
-
const vendoredPkgPath =
|
|
790
|
+
const vendoredPkgPath = join2(vendorPath, "package.json");
|
|
83
791
|
const vendoredPkg = await readPackageJson(vendoredPkgPath);
|
|
84
792
|
if (vendoredPkg.private === true) {
|
|
85
793
|
delete vendoredPkg.private;
|
|
@@ -102,7 +810,7 @@ async function countFiles(dir) {
|
|
|
102
810
|
let count = 0;
|
|
103
811
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
104
812
|
for (const entry of entries) {
|
|
105
|
-
const full =
|
|
813
|
+
const full = join2(dir, entry.name);
|
|
106
814
|
if (entry.isDirectory()) {
|
|
107
815
|
count += await countFiles(full);
|
|
108
816
|
} else if (entry.isFile()) {
|
|
@@ -117,55 +825,38 @@ async function countFiles(dir) {
|
|
|
117
825
|
return count;
|
|
118
826
|
}
|
|
119
827
|
|
|
120
|
-
// src/
|
|
121
|
-
var
|
|
122
|
-
|
|
123
|
-
Usage:
|
|
124
|
-
hogsend eject <package> [--force] [--cwd <dir>]
|
|
828
|
+
// src/commands/eject.ts
|
|
829
|
+
var usage3 = `hogsend eject <package> [--force] [--cwd <dir>]
|
|
125
830
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
rewrite the consumer dependency to file:./vendor/<name>.
|
|
129
|
-
Every other dependency keeps upgrading via pnpm up.
|
|
831
|
+
Copy a @hogsend/* package's source into vendor/<name> and rewrite the consumer
|
|
832
|
+
dependency to file:./vendor/<name>. Every other dependency keeps upgrading.
|
|
130
833
|
|
|
131
834
|
Options:
|
|
132
|
-
--force
|
|
133
|
-
--cwd <dir>
|
|
134
|
-
-h, --help
|
|
835
|
+
--force Overwrite an existing vendor/<name>.
|
|
836
|
+
--cwd <dir> Consumer repo root (defaults to the current directory).
|
|
837
|
+
-h, --help Show this help.
|
|
135
838
|
|
|
136
839
|
After ejecting, run: pnpm install`;
|
|
137
|
-
var RED = "\x1B[31m";
|
|
138
|
-
var RESET = "\x1B[0m";
|
|
139
|
-
function fail(message) {
|
|
140
|
-
process.stderr.write(`${RED}error${RESET} ${message}
|
|
141
|
-
`);
|
|
142
|
-
process.exit(1);
|
|
143
|
-
}
|
|
144
840
|
function resolveSourceDir(pkg, consumerRoot) {
|
|
145
|
-
const direct =
|
|
146
|
-
if (
|
|
841
|
+
const direct = join3(consumerRoot, "node_modules", pkg, "package.json");
|
|
842
|
+
if (existsSync3(direct)) {
|
|
147
843
|
return dirname(realpathSync(direct));
|
|
148
844
|
}
|
|
149
|
-
const require2 =
|
|
845
|
+
const require2 = createRequire2(`${consumerRoot}${sep2}`);
|
|
150
846
|
try {
|
|
151
847
|
const entry = require2.resolve(pkg);
|
|
152
848
|
let dir = dirname(entry);
|
|
153
849
|
while (dir !== dirname(dir)) {
|
|
154
|
-
|
|
155
|
-
if (existsSync2(candidate)) {
|
|
156
|
-
return dir;
|
|
157
|
-
}
|
|
850
|
+
if (existsSync3(join3(dir, "package.json"))) return dir;
|
|
158
851
|
dir = dirname(dir);
|
|
159
852
|
}
|
|
160
853
|
} catch {
|
|
161
854
|
}
|
|
162
|
-
|
|
163
|
-
`cannot resolve ${pkg} from ${consumerRoot}. Is it installed? Run pnpm install first.`
|
|
164
|
-
);
|
|
855
|
+
return null;
|
|
165
856
|
}
|
|
166
|
-
async function
|
|
167
|
-
const { values, positionals } =
|
|
168
|
-
args,
|
|
857
|
+
async function run3(ctx) {
|
|
858
|
+
const { values, positionals } = parseArgs3({
|
|
859
|
+
args: ctx.argv,
|
|
169
860
|
allowPositionals: true,
|
|
170
861
|
options: {
|
|
171
862
|
force: { type: "boolean", default: false },
|
|
@@ -174,58 +865,1530 @@ async function runEject(args) {
|
|
|
174
865
|
}
|
|
175
866
|
});
|
|
176
867
|
if (values.help) {
|
|
177
|
-
|
|
178
|
-
`);
|
|
868
|
+
ctx.out.log(usage3);
|
|
179
869
|
return;
|
|
180
870
|
}
|
|
181
871
|
const pkg = positionals[0];
|
|
182
872
|
if (!pkg) {
|
|
183
|
-
fail(
|
|
873
|
+
ctx.out.fail(
|
|
874
|
+
"eject requires a package name, e.g. hogsend eject @hogsend/engine"
|
|
875
|
+
);
|
|
184
876
|
}
|
|
185
877
|
const consumerRoot = values.cwd ?? process.cwd();
|
|
186
878
|
const sourceDir = resolveSourceDir(pkg, consumerRoot);
|
|
879
|
+
if (!sourceDir) {
|
|
880
|
+
ctx.out.fail(
|
|
881
|
+
`cannot resolve ${pkg} from ${consumerRoot}. Is it installed? Run pnpm install first.`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
187
884
|
try {
|
|
188
|
-
const result = await
|
|
189
|
-
pkg
|
|
190
|
-
consumerRoot,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
885
|
+
const result = await ctx.out.step(
|
|
886
|
+
`Ejecting ${pkg}`,
|
|
887
|
+
() => eject({ pkg, consumerRoot, sourceDir, force: values.force })
|
|
888
|
+
);
|
|
889
|
+
if (ctx.json) {
|
|
890
|
+
ctx.out.json(result);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
ctx.out.note(
|
|
894
|
+
[
|
|
895
|
+
`copied ${result.copiedFiles} files -> ${result.vendorPath}`,
|
|
896
|
+
`dependency ${result.depSpecBefore} -> ${color.cyan(result.depSpecAfter)}`,
|
|
897
|
+
"",
|
|
898
|
+
`Now run: ${color.cyan(result.followUp)}`
|
|
899
|
+
].join("\n"),
|
|
900
|
+
`Ejected ${result.pkg}`
|
|
201
901
|
);
|
|
202
902
|
} catch (error) {
|
|
203
903
|
if (error instanceof EjectError) {
|
|
204
|
-
fail(error.message);
|
|
904
|
+
ctx.out.fail(error.message);
|
|
205
905
|
}
|
|
206
906
|
throw error;
|
|
207
907
|
}
|
|
208
908
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
909
|
+
var ejectCommand = {
|
|
910
|
+
name: "eject",
|
|
911
|
+
summary: "Vendor a @hogsend/* package into vendor/<name>",
|
|
912
|
+
usage: usage3,
|
|
913
|
+
run: run3
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
// src/commands/events.ts
|
|
917
|
+
import { parseArgs as parseArgs4 } from "util";
|
|
918
|
+
var usage4 = `hogsend events <userId> [options]
|
|
919
|
+
|
|
920
|
+
Stream the event history for a single user, newest first. Wraps
|
|
921
|
+
GET /v1/admin/events?userId=<userId>.
|
|
922
|
+
|
|
923
|
+
Arguments:
|
|
924
|
+
<userId> The user (distinct) id to fetch events for. Required.
|
|
925
|
+
|
|
926
|
+
Options:
|
|
927
|
+
--event <name> Filter to a single event name.
|
|
928
|
+
--from <iso> Only events at/after this ISO-8601 timestamp.
|
|
929
|
+
--to <iso> Only events at/before this ISO-8601 timestamp.
|
|
930
|
+
--limit <n> Max events to return (1-100, default 50).
|
|
931
|
+
--offset <n> Pagination offset (default 0).
|
|
932
|
+
--json Emit machine-readable JSON only.
|
|
933
|
+
-h, --help Show this help.
|
|
934
|
+
|
|
935
|
+
Examples:
|
|
936
|
+
hogsend events user_123
|
|
937
|
+
hogsend events user_123 --event signup --limit 10
|
|
938
|
+
hogsend events user_123 --from 2026-01-01T00:00:00Z --json`;
|
|
939
|
+
async function run4(ctx) {
|
|
940
|
+
const { values, positionals } = parseArgs4({
|
|
941
|
+
args: ctx.argv,
|
|
942
|
+
allowPositionals: true,
|
|
943
|
+
options: {
|
|
944
|
+
event: { type: "string" },
|
|
945
|
+
from: { type: "string" },
|
|
946
|
+
to: { type: "string" },
|
|
947
|
+
limit: { type: "string" },
|
|
948
|
+
offset: { type: "string" },
|
|
949
|
+
help: { type: "boolean", short: "h", default: false }
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
if (values.help) {
|
|
953
|
+
ctx.out.log(usage4);
|
|
214
954
|
return;
|
|
215
955
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
956
|
+
const userId = positionals[0];
|
|
957
|
+
if (!userId) {
|
|
958
|
+
ctx.out.fail("events requires a userId, e.g. hogsend events user_123");
|
|
959
|
+
}
|
|
960
|
+
const limit = parseNumber(values.limit, "limit", ctx);
|
|
961
|
+
const offset = parseNumber(values.offset, "offset", ctx);
|
|
962
|
+
const query = {
|
|
963
|
+
userId,
|
|
964
|
+
event: values.event,
|
|
965
|
+
from: values.from,
|
|
966
|
+
to: values.to,
|
|
967
|
+
limit,
|
|
968
|
+
offset
|
|
969
|
+
};
|
|
970
|
+
let data;
|
|
971
|
+
try {
|
|
972
|
+
data = await ctx.out.step(
|
|
973
|
+
`Fetching events for ${userId}`,
|
|
974
|
+
() => ctx.http.get("/v1/admin/events", query)
|
|
975
|
+
);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (isHttpError(error)) {
|
|
978
|
+
ctx.out.fail(error.message);
|
|
979
|
+
}
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
if (ctx.json) {
|
|
983
|
+
ctx.out.json(data);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} events`);
|
|
987
|
+
if (data.events.length === 0) {
|
|
988
|
+
ctx.out.note(
|
|
989
|
+
`No events found for ${color.cyan(userId)}.`,
|
|
990
|
+
"Empty event stream"
|
|
991
|
+
);
|
|
992
|
+
ctx.out.outro(color.dim("Nothing to show."));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const rows = data.events.map((e) => ({
|
|
996
|
+
occurredAt: e.occurredAt,
|
|
997
|
+
event: e.event,
|
|
998
|
+
properties: summarizeProps(e.properties),
|
|
999
|
+
id: e.id
|
|
1000
|
+
}));
|
|
1001
|
+
ctx.out.table(rows, ["occurredAt", "event", "properties", "id"]);
|
|
1002
|
+
const shown = data.events.length;
|
|
1003
|
+
const through = data.offset + shown;
|
|
1004
|
+
ctx.out.outro(
|
|
1005
|
+
`${color.green(String(shown))} event${shown === 1 ? "" : "s"} ` + color.dim(`(${data.offset + 1}-${through} of ${data.total})`)
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
function parseNumber(raw, name, ctx) {
|
|
1009
|
+
if (raw === void 0) return void 0;
|
|
1010
|
+
const n = Number(raw);
|
|
1011
|
+
if (!Number.isFinite(n)) {
|
|
1012
|
+
ctx.out.fail(`--${name} must be a number, got "${raw}"`);
|
|
224
1013
|
}
|
|
1014
|
+
return n;
|
|
225
1015
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
1016
|
+
function summarizeProps(props) {
|
|
1017
|
+
if (!props) return "";
|
|
1018
|
+
const keys = Object.keys(props);
|
|
1019
|
+
if (keys.length === 0) return "";
|
|
1020
|
+
const preview = JSON.stringify(props);
|
|
1021
|
+
return preview.length > 60 ? `${preview.slice(0, 57)}...` : preview;
|
|
1022
|
+
}
|
|
1023
|
+
var eventsCommand = {
|
|
1024
|
+
name: "events",
|
|
1025
|
+
summary: "Stream a single user's event history",
|
|
1026
|
+
usage: usage4,
|
|
1027
|
+
run: run4
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
// src/commands/journeys.ts
|
|
1031
|
+
import { parseArgs as parseArgs5 } from "util";
|
|
1032
|
+
var usage5 = `hogsend journeys <subcommand> [options]
|
|
1033
|
+
|
|
1034
|
+
Inspect and toggle journeys via the admin API (/v1/admin/journeys).
|
|
1035
|
+
|
|
1036
|
+
Subcommands:
|
|
1037
|
+
list List journeys with status, trigger, and state counts.
|
|
1038
|
+
get <id> Show one journey: trigger, exitOn, counts, recent states.
|
|
1039
|
+
enable <id> Enable a journey (PATCH { enabled: true }).
|
|
1040
|
+
disable <id> Disable a journey (PATCH { enabled: false }).
|
|
1041
|
+
|
|
1042
|
+
Options:
|
|
1043
|
+
list:
|
|
1044
|
+
--enabled <true|false> Filter by enabled state.
|
|
1045
|
+
--limit <n> Page size (1-100, default 50).
|
|
1046
|
+
--offset <n> Page offset (default 0).
|
|
1047
|
+
--json Emit machine-readable JSON only.
|
|
1048
|
+
-h, --help Show this help.
|
|
1049
|
+
|
|
1050
|
+
Examples:
|
|
1051
|
+
hogsend journeys list --enabled true
|
|
1052
|
+
hogsend journeys get activation-welcome --json
|
|
1053
|
+
hogsend journeys disable churn-prevention`;
|
|
1054
|
+
function badge2() {
|
|
1055
|
+
return `${color.bgMagenta(color.black(" hogsend "))} journeys`;
|
|
1056
|
+
}
|
|
1057
|
+
function statusColor(enabled) {
|
|
1058
|
+
return enabled ? color.green("enabled") : color.yellow("disabled");
|
|
1059
|
+
}
|
|
1060
|
+
async function runList2(ctx) {
|
|
1061
|
+
const { values } = parseArgs5({
|
|
1062
|
+
args: ctx.argv,
|
|
1063
|
+
allowPositionals: true,
|
|
1064
|
+
options: {
|
|
1065
|
+
enabled: { type: "string" },
|
|
1066
|
+
limit: { type: "string" },
|
|
1067
|
+
offset: { type: "string" },
|
|
1068
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
if (values.help) {
|
|
1072
|
+
ctx.out.log(usage5);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (values.enabled !== void 0 && !["true", "false"].includes(values.enabled)) {
|
|
1076
|
+
ctx.out.fail("--enabled must be 'true' or 'false'");
|
|
1077
|
+
}
|
|
1078
|
+
const query = {
|
|
1079
|
+
enabled: values.enabled,
|
|
1080
|
+
limit: values.limit,
|
|
1081
|
+
offset: values.offset
|
|
1082
|
+
};
|
|
1083
|
+
if (!ctx.json) ctx.out.intro(badge2());
|
|
1084
|
+
const data = await ctx.out.step(
|
|
1085
|
+
"Fetching journeys",
|
|
1086
|
+
() => ctx.http.get("/v1/admin/journeys", query)
|
|
1087
|
+
);
|
|
1088
|
+
if (ctx.json) {
|
|
1089
|
+
ctx.out.json(data);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (data.journeys.length === 0) {
|
|
1093
|
+
ctx.out.note("No journeys matched.", "Journeys");
|
|
1094
|
+
} else {
|
|
1095
|
+
ctx.out.table(
|
|
1096
|
+
data.journeys.map((j) => ({
|
|
1097
|
+
id: j.id,
|
|
1098
|
+
name: j.name,
|
|
1099
|
+
status: statusColor(j.enabled),
|
|
1100
|
+
trigger: j.trigger.event,
|
|
1101
|
+
active: j.counts.active,
|
|
1102
|
+
waiting: j.counts.waiting,
|
|
1103
|
+
completed: j.counts.completed,
|
|
1104
|
+
failed: j.counts.failed
|
|
1105
|
+
})),
|
|
1106
|
+
[
|
|
1107
|
+
"id",
|
|
1108
|
+
"name",
|
|
1109
|
+
"status",
|
|
1110
|
+
"trigger",
|
|
1111
|
+
"active",
|
|
1112
|
+
"waiting",
|
|
1113
|
+
"completed",
|
|
1114
|
+
"failed"
|
|
1115
|
+
]
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
ctx.out.outro(
|
|
1119
|
+
`${data.journeys.length} of ${data.total} journey(s) \u2014 offset ${data.offset}, limit ${data.limit}`
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
async function runGet2(ctx, id) {
|
|
1123
|
+
if (!id) {
|
|
1124
|
+
ctx.out.fail(
|
|
1125
|
+
"journeys get requires a journey id, e.g. hogsend journeys get activation-welcome"
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
if (!ctx.json) ctx.out.intro(badge2());
|
|
1129
|
+
const data = await ctx.out.step(
|
|
1130
|
+
`Fetching journey ${id}`,
|
|
1131
|
+
() => ctx.http.get(
|
|
1132
|
+
`/v1/admin/journeys/${encodeURIComponent(id)}`
|
|
1133
|
+
)
|
|
1134
|
+
);
|
|
1135
|
+
if (ctx.json) {
|
|
1136
|
+
ctx.out.json(data);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
const j = data.journey;
|
|
1140
|
+
ctx.out.kv(
|
|
1141
|
+
{
|
|
1142
|
+
id: j.id,
|
|
1143
|
+
name: j.name,
|
|
1144
|
+
description: j.description ?? "",
|
|
1145
|
+
status: statusColor(j.enabled),
|
|
1146
|
+
trigger: j.trigger.event,
|
|
1147
|
+
entryLimit: j.entryLimit,
|
|
1148
|
+
exitOn: j.exitOn?.map((e) => e.event).join(", ") ?? "(none)"
|
|
1149
|
+
},
|
|
1150
|
+
"Journey"
|
|
1151
|
+
);
|
|
1152
|
+
ctx.out.kv(
|
|
1153
|
+
{
|
|
1154
|
+
active: j.counts.active,
|
|
1155
|
+
waiting: j.counts.waiting,
|
|
1156
|
+
completed: j.counts.completed,
|
|
1157
|
+
failed: j.counts.failed,
|
|
1158
|
+
exited: j.counts.exited
|
|
1159
|
+
},
|
|
1160
|
+
"Counts"
|
|
1161
|
+
);
|
|
1162
|
+
if (j.recentStates.length === 0) {
|
|
1163
|
+
ctx.out.note("No recent journey instances.", "Recent states");
|
|
1164
|
+
} else {
|
|
1165
|
+
ctx.out.table(
|
|
1166
|
+
j.recentStates.map((s) => ({
|
|
1167
|
+
userId: s.userId,
|
|
1168
|
+
email: s.userEmail,
|
|
1169
|
+
status: s.status,
|
|
1170
|
+
node: s.currentNodeId,
|
|
1171
|
+
updatedAt: s.updatedAt
|
|
1172
|
+
})),
|
|
1173
|
+
["userId", "email", "status", "node", "updatedAt"]
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
ctx.out.outro(`Journey ${j.id} is ${j.enabled ? "enabled" : "disabled"}.`);
|
|
1177
|
+
}
|
|
1178
|
+
async function runToggle(ctx, id, enabled) {
|
|
1179
|
+
const verb = enabled ? "enable" : "disable";
|
|
1180
|
+
if (!id) {
|
|
1181
|
+
ctx.out.fail(
|
|
1182
|
+
`journeys ${verb} requires a journey id, e.g. hogsend journeys ${verb} activation-welcome`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
if (!ctx.json) ctx.out.intro(badge2());
|
|
1186
|
+
const data = await ctx.out.step(
|
|
1187
|
+
`${enabled ? "Enabling" : "Disabling"} ${id}`,
|
|
1188
|
+
() => ctx.http.patch(
|
|
1189
|
+
`/v1/admin/journeys/${encodeURIComponent(id)}`,
|
|
1190
|
+
{ enabled }
|
|
1191
|
+
)
|
|
1192
|
+
);
|
|
1193
|
+
if (ctx.json) {
|
|
1194
|
+
ctx.out.json(data);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
const j = data.journey;
|
|
1198
|
+
ctx.out.note(
|
|
1199
|
+
[
|
|
1200
|
+
`${color.bold(j.name)} (${j.id})`,
|
|
1201
|
+
`status: ${statusColor(j.enabled)}`,
|
|
1202
|
+
`updated: ${j.updatedAt}`
|
|
1203
|
+
].join("\n"),
|
|
1204
|
+
`Journey ${enabled ? "enabled" : "disabled"}`
|
|
1205
|
+
);
|
|
1206
|
+
ctx.out.outro(`${j.id} is now ${statusColor(j.enabled)}.`);
|
|
1207
|
+
}
|
|
1208
|
+
async function run5(ctx) {
|
|
1209
|
+
const sub = ctx.argv[0];
|
|
1210
|
+
const rest = ctx.argv.slice(1);
|
|
1211
|
+
const subCtx = { ...ctx, argv: rest };
|
|
1212
|
+
try {
|
|
1213
|
+
switch (sub) {
|
|
1214
|
+
case "list":
|
|
1215
|
+
await runList2(subCtx);
|
|
1216
|
+
return;
|
|
1217
|
+
case "get": {
|
|
1218
|
+
const id = rest.find((a) => !a.startsWith("-"));
|
|
1219
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
1220
|
+
ctx.out.log(usage5);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
await runGet2(subCtx, id);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
case "enable": {
|
|
1227
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
1228
|
+
ctx.out.log(usage5);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
await runToggle(
|
|
1232
|
+
subCtx,
|
|
1233
|
+
rest.find((a) => !a.startsWith("-")),
|
|
1234
|
+
true
|
|
1235
|
+
);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
case "disable": {
|
|
1239
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
1240
|
+
ctx.out.log(usage5);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
await runToggle(
|
|
1244
|
+
subCtx,
|
|
1245
|
+
rest.find((a) => !a.startsWith("-")),
|
|
1246
|
+
false
|
|
1247
|
+
);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
case void 0:
|
|
1251
|
+
ctx.out.fail(
|
|
1252
|
+
`journeys requires a subcommand (list|get|enable|disable). Run: hogsend journeys --help`
|
|
1253
|
+
);
|
|
1254
|
+
return;
|
|
1255
|
+
default:
|
|
1256
|
+
ctx.out.fail(
|
|
1257
|
+
`unknown journeys subcommand '${sub}'. Expected list|get|enable|disable.`
|
|
1258
|
+
);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
if (isHttpError(error)) {
|
|
1263
|
+
if (error.status === 404) {
|
|
1264
|
+
ctx.out.fail("journey not found");
|
|
1265
|
+
}
|
|
1266
|
+
ctx.out.fail(error.message);
|
|
1267
|
+
}
|
|
1268
|
+
throw error;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
var journeysCommand = {
|
|
1272
|
+
name: "journeys",
|
|
1273
|
+
summary: "List, inspect, enable, and disable journeys",
|
|
1274
|
+
usage: usage5,
|
|
1275
|
+
run: run5
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
// src/commands/patch.ts
|
|
1279
|
+
import { spawnSync } from "child_process";
|
|
1280
|
+
import { parseArgs as parseArgs6 } from "util";
|
|
1281
|
+
var usage6 = `hogsend patch <package> [--cwd <dir>]
|
|
1282
|
+
|
|
1283
|
+
Thin wrapper over pnpm's native patch flow. Runs \`pnpm patch <package>\`, which
|
|
1284
|
+
extracts the package into a temp dir and prints the path to edit. After editing,
|
|
1285
|
+
commit the patch with the command pnpm prints (\`pnpm patch-commit <dir>\`).
|
|
1286
|
+
|
|
1287
|
+
This does NOT replace scripts/patch-check.sh (the patch re-apply contract).
|
|
1288
|
+
|
|
1289
|
+
Options:
|
|
1290
|
+
--cwd <dir> Project root to run pnpm in (defaults to current directory).
|
|
1291
|
+
-h, --help Show this help.`;
|
|
1292
|
+
async function run6(ctx) {
|
|
1293
|
+
const { values, positionals } = parseArgs6({
|
|
1294
|
+
args: ctx.argv,
|
|
1295
|
+
allowPositionals: true,
|
|
1296
|
+
options: {
|
|
1297
|
+
cwd: { type: "string" },
|
|
1298
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
if (values.help) {
|
|
1302
|
+
ctx.out.log(usage6);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const pkg = positionals[0];
|
|
1306
|
+
if (!pkg) {
|
|
1307
|
+
ctx.out.fail(
|
|
1308
|
+
"patch requires a package name, e.g. hogsend patch @hogsend/engine"
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
const cwd = values.cwd ?? process.cwd();
|
|
1312
|
+
const result = spawnSync("pnpm", ["patch", pkg], {
|
|
1313
|
+
cwd,
|
|
1314
|
+
stdio: ctx.json ? "ignore" : "inherit"
|
|
1315
|
+
});
|
|
1316
|
+
if (ctx.json) {
|
|
1317
|
+
ctx.out.json({
|
|
1318
|
+
package: pkg,
|
|
1319
|
+
command: `pnpm patch ${pkg}`,
|
|
1320
|
+
status: result.status,
|
|
1321
|
+
ok: result.status === 0
|
|
1322
|
+
});
|
|
1323
|
+
if (result.status !== 0) process.exit(1);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
if (result.status !== 0) {
|
|
1327
|
+
ctx.out.fail(`pnpm patch ${pkg} exited with code ${result.status ?? "?"}`);
|
|
1328
|
+
}
|
|
1329
|
+
ctx.out.note(
|
|
1330
|
+
[
|
|
1331
|
+
"pnpm extracted the package to a temp dir (printed above).",
|
|
1332
|
+
"Edit the files, then commit the patch:",
|
|
1333
|
+
"",
|
|
1334
|
+
color.cyan("pnpm patch-commit <dir>")
|
|
1335
|
+
].join("\n"),
|
|
1336
|
+
"Next steps"
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
var patchCommand = {
|
|
1340
|
+
name: "patch",
|
|
1341
|
+
summary: "Patch a package via pnpm's native patch flow",
|
|
1342
|
+
usage: usage6,
|
|
1343
|
+
run: run6
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
// src/commands/setup.ts
|
|
1347
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1348
|
+
import { randomBytes } from "crypto";
|
|
1349
|
+
import { copyFileSync, existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1350
|
+
import { join as join4 } from "path";
|
|
1351
|
+
import { parseArgs as parseArgs7 } from "util";
|
|
1352
|
+
import { confirm } from "@clack/prompts";
|
|
1353
|
+
|
|
1354
|
+
// src/lib/prompt.ts
|
|
1355
|
+
import { cancel as cancel2, isCancel } from "@clack/prompts";
|
|
1356
|
+
function bail(value) {
|
|
1357
|
+
if (isCancel(value)) {
|
|
1358
|
+
cancel2("Cancelled.");
|
|
1359
|
+
process.exit(0);
|
|
1360
|
+
}
|
|
1361
|
+
return value;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/commands/setup.ts
|
|
1365
|
+
var usage7 = `hogsend setup [--cwd <dir>] [--yes] [--json]
|
|
1366
|
+
|
|
1367
|
+
Interactive local onboarding for a scaffolded Hogsend app. Mirrors the
|
|
1368
|
+
create-hogsend "next steps":
|
|
1369
|
+
|
|
1370
|
+
1. docker compose up -d # Postgres + Redis + Hatchet-Lite
|
|
1371
|
+
2. cp .env.example .env (if missing)
|
|
1372
|
+
3. generate a BETTER_AUTH_SECRET (if still the placeholder)
|
|
1373
|
+
4. pnpm db:migrate # engine track then client track
|
|
1374
|
+
|
|
1375
|
+
Options:
|
|
1376
|
+
--cwd <dir> Project root to run in (defaults to the current directory).
|
|
1377
|
+
--yes, -y Skip confirmation prompts (assume yes). Implied by --json.
|
|
1378
|
+
--json Run non-interactively and emit a single JSON result document.
|
|
1379
|
+
-h, --help Show this help.
|
|
1380
|
+
|
|
1381
|
+
Run ${color.cyan("hogsend doctor")} afterwards to verify the instance is healthy.`;
|
|
1382
|
+
function generateSecret() {
|
|
1383
|
+
return randomBytes(32).toString("hex");
|
|
1384
|
+
}
|
|
1385
|
+
var SECRET_KEY = "BETTER_AUTH_SECRET";
|
|
1386
|
+
var PLACEHOLDER_PREFIX = "change-me";
|
|
1387
|
+
function ensureEnv(cwd) {
|
|
1388
|
+
const envPath = join4(cwd, ".env");
|
|
1389
|
+
const examplePath = join4(cwd, ".env.example");
|
|
1390
|
+
let copied;
|
|
1391
|
+
if (existsSync4(envPath)) {
|
|
1392
|
+
copied = {
|
|
1393
|
+
step: "env",
|
|
1394
|
+
status: "skipped",
|
|
1395
|
+
detail: ".env already exists"
|
|
1396
|
+
};
|
|
1397
|
+
} else if (existsSync4(examplePath)) {
|
|
1398
|
+
copyFileSync(examplePath, envPath);
|
|
1399
|
+
copied = {
|
|
1400
|
+
step: "env",
|
|
1401
|
+
status: "ok",
|
|
1402
|
+
detail: "copied .env.example -> .env"
|
|
1403
|
+
};
|
|
1404
|
+
} else {
|
|
1405
|
+
copied = {
|
|
1406
|
+
step: "env",
|
|
1407
|
+
status: "failed",
|
|
1408
|
+
detail: "no .env and no .env.example to copy from"
|
|
1409
|
+
};
|
|
1410
|
+
return {
|
|
1411
|
+
copied,
|
|
1412
|
+
secret: {
|
|
1413
|
+
step: "secret",
|
|
1414
|
+
status: "skipped",
|
|
1415
|
+
detail: "skipped \u2014 no .env"
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
let raw;
|
|
1420
|
+
try {
|
|
1421
|
+
raw = readFileSync2(envPath, "utf8");
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
return {
|
|
1424
|
+
copied,
|
|
1425
|
+
secret: {
|
|
1426
|
+
step: "secret",
|
|
1427
|
+
status: "failed",
|
|
1428
|
+
detail: `could not read .env: ${err instanceof Error ? err.message : String(err)}`
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
const lines = raw.split(/\r?\n/);
|
|
1433
|
+
const idx = lines.findIndex(
|
|
1434
|
+
(l) => l.replace(/^export\s+/, "").trimStart().startsWith(`${SECRET_KEY}=`)
|
|
1435
|
+
);
|
|
1436
|
+
const existingLine = idx === -1 ? void 0 : lines[idx];
|
|
1437
|
+
const current = existingLine === void 0 ? void 0 : existingLine.slice(existingLine.indexOf("=") + 1).trim();
|
|
1438
|
+
const isPlaceholder = current === void 0 || current === "" || current.startsWith(PLACEHOLDER_PREFIX);
|
|
1439
|
+
if (!isPlaceholder) {
|
|
1440
|
+
return {
|
|
1441
|
+
copied,
|
|
1442
|
+
secret: {
|
|
1443
|
+
step: "secret",
|
|
1444
|
+
status: "skipped",
|
|
1445
|
+
detail: `${SECRET_KEY} already set`
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
const secret = generateSecret();
|
|
1450
|
+
const newLine = `${SECRET_KEY}=${secret}`;
|
|
1451
|
+
if (idx === -1) {
|
|
1452
|
+
if (raw.length > 0 && !raw.endsWith("\n")) lines.push("");
|
|
1453
|
+
lines.push(newLine);
|
|
1454
|
+
} else {
|
|
1455
|
+
lines[idx] = newLine;
|
|
1456
|
+
}
|
|
1457
|
+
writeFileSync2(envPath, lines.join("\n"));
|
|
1458
|
+
return {
|
|
1459
|
+
copied,
|
|
1460
|
+
secret: {
|
|
1461
|
+
step: "secret",
|
|
1462
|
+
status: "ok",
|
|
1463
|
+
detail: `generated ${SECRET_KEY} (64-char hex)`
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
function runCmd(cmd, args, cwd, json) {
|
|
1468
|
+
const result = spawnSync2(cmd, args, {
|
|
1469
|
+
cwd,
|
|
1470
|
+
// In json mode stay silent (we report structured status); otherwise stream
|
|
1471
|
+
// so the user sees docker / migration output inline.
|
|
1472
|
+
stdio: json ? "ignore" : "inherit"
|
|
1473
|
+
});
|
|
1474
|
+
return { status: result.status, ok: result.status === 0 };
|
|
1475
|
+
}
|
|
1476
|
+
async function run7(ctx) {
|
|
1477
|
+
const { values } = parseArgs7({
|
|
1478
|
+
args: ctx.argv,
|
|
1479
|
+
allowPositionals: true,
|
|
1480
|
+
options: {
|
|
1481
|
+
cwd: { type: "string" },
|
|
1482
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
1483
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
if (values.help) {
|
|
1487
|
+
ctx.out.log(usage7);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
const cwd = values.cwd ?? process.cwd();
|
|
1491
|
+
if (!existsSync4(join4(cwd, "package.json"))) {
|
|
1492
|
+
ctx.out.fail(
|
|
1493
|
+
`no package.json in ${cwd} \u2014 run setup from a scaffolded Hogsend app (or pass --cwd).`
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
const hasCompose = existsSync4(join4(cwd, "docker-compose.yml")) || existsSync4(join4(cwd, "docker-compose.yaml")) || existsSync4(join4(cwd, "compose.yml")) || existsSync4(join4(cwd, "compose.yaml"));
|
|
1497
|
+
const skipConfirm = ctx.json || values.yes;
|
|
1498
|
+
if (!ctx.json) {
|
|
1499
|
+
ctx.out.intro(
|
|
1500
|
+
`${color.bgMagenta(color.black(" hogsend "))} ${color.dim("local onboarding")}`
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
if (ctx.out.interactive && !skipConfirm) {
|
|
1504
|
+
const proceed = bail(
|
|
1505
|
+
await confirm({
|
|
1506
|
+
message: `Set up local infra in ${color.cyan(cwd)}? (docker compose up, .env, db:migrate)`
|
|
1507
|
+
})
|
|
1508
|
+
);
|
|
1509
|
+
if (!proceed) {
|
|
1510
|
+
ctx.out.outro(color.dim("Nothing changed."));
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
const results = [];
|
|
1515
|
+
if (hasCompose) {
|
|
1516
|
+
const docker = await ctx.out.step(
|
|
1517
|
+
"Starting infra (docker compose up -d)",
|
|
1518
|
+
async () => runCmd("docker", ["compose", "up", "-d"], cwd, ctx.json)
|
|
1519
|
+
);
|
|
1520
|
+
results.push({
|
|
1521
|
+
step: "docker",
|
|
1522
|
+
status: docker.ok ? "ok" : "failed",
|
|
1523
|
+
detail: docker.ok ? "Postgres + Redis + Hatchet-Lite up" : `docker compose exited with code ${docker.status ?? "?"}`
|
|
1524
|
+
});
|
|
1525
|
+
} else {
|
|
1526
|
+
results.push({
|
|
1527
|
+
step: "docker",
|
|
1528
|
+
status: "skipped",
|
|
1529
|
+
detail: "no docker-compose file found"
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
const env = await ctx.out.step(
|
|
1533
|
+
"Preparing .env + auth secret",
|
|
1534
|
+
async () => ensureEnv(cwd)
|
|
1535
|
+
);
|
|
1536
|
+
results.push(env.copied, env.secret);
|
|
1537
|
+
const dockerFailed = results.some(
|
|
1538
|
+
(r) => r.step === "docker" && r.status === "failed"
|
|
1539
|
+
);
|
|
1540
|
+
if (dockerFailed) {
|
|
1541
|
+
results.push({
|
|
1542
|
+
step: "migrate",
|
|
1543
|
+
status: "skipped",
|
|
1544
|
+
detail: "skipped \u2014 docker compose failed; bring infra up then run pnpm db:migrate"
|
|
1545
|
+
});
|
|
1546
|
+
} else {
|
|
1547
|
+
const migrate = await ctx.out.step(
|
|
1548
|
+
"Running migrations (pnpm db:migrate)",
|
|
1549
|
+
async () => runCmd("pnpm", ["db:migrate"], cwd, ctx.json)
|
|
1550
|
+
);
|
|
1551
|
+
results.push({
|
|
1552
|
+
step: "migrate",
|
|
1553
|
+
status: migrate.ok ? "ok" : "failed",
|
|
1554
|
+
detail: migrate.ok ? "engine + client migrations applied" : `pnpm db:migrate exited with code ${migrate.status ?? "?"}`
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
1558
|
+
const ok = failed.length === 0;
|
|
1559
|
+
if (ctx.json) {
|
|
1560
|
+
ctx.out.json({
|
|
1561
|
+
ok,
|
|
1562
|
+
cwd,
|
|
1563
|
+
steps: results
|
|
1564
|
+
});
|
|
1565
|
+
if (!ok) process.exit(1);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
ctx.out.table(
|
|
1569
|
+
results.map((r) => ({
|
|
1570
|
+
step: r.step,
|
|
1571
|
+
status: r.status === "ok" ? color.green("ok") : r.status === "skipped" ? color.dim("skipped") : color.red("failed"),
|
|
1572
|
+
detail: r.detail
|
|
1573
|
+
})),
|
|
1574
|
+
["step", "status", "detail"]
|
|
1575
|
+
);
|
|
1576
|
+
ctx.out.note(
|
|
1577
|
+
[
|
|
1578
|
+
`${color.cyan("pnpm dev")} ${color.dim("# HTTP API on :3002")}`,
|
|
1579
|
+
`${color.cyan("pnpm worker:dev")} ${color.dim("# Hatchet worker, 2nd terminal")}`,
|
|
1580
|
+
"",
|
|
1581
|
+
`${color.dim("Verify with")} ${color.cyan("hogsend doctor")}${color.dim(".")}`,
|
|
1582
|
+
`${color.dim("Grab HATCHET_CLIENT_TOKEN at")} ${color.cyan("http://localhost:8888")} ${color.dim("and set it in .env.")}`
|
|
1583
|
+
].join("\n"),
|
|
1584
|
+
"Next steps"
|
|
1585
|
+
);
|
|
1586
|
+
if (!ok) {
|
|
1587
|
+
ctx.out.fail(
|
|
1588
|
+
`${failed.length} step(s) failed \u2014 see the table above. Fix and re-run hogsend setup.`
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
ctx.out.outro(
|
|
1592
|
+
`${color.green("Done.")} ${color.dim("Local infra is up \u2014 go write a journey.")}`
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
var setupCommand = {
|
|
1596
|
+
name: "setup",
|
|
1597
|
+
summary: "Local onboarding: docker compose up, gen secret, db:migrate",
|
|
1598
|
+
usage: usage7,
|
|
1599
|
+
run: run7
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
// src/commands/skills.ts
|
|
1603
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1604
|
+
import { join as join5 } from "path";
|
|
1605
|
+
import { parseArgs as parseArgs8 } from "util";
|
|
1606
|
+
import { multiselect } from "@clack/prompts";
|
|
1607
|
+
var usage8 = `hogsend skills <subcommand> [options]
|
|
1608
|
+
|
|
1609
|
+
Manage the Claude Code skills bundled with @hogsend/cli. Bundled skills teach
|
|
1610
|
+
agents how to drive the hogsend CLI; \`add\` copies them into your project's
|
|
1611
|
+
./.claude/skills/<name>/ so Claude Code can discover them.
|
|
1612
|
+
|
|
1613
|
+
Subcommands:
|
|
1614
|
+
list List bundled skills + whether each is installed.
|
|
1615
|
+
add [name] [--force] Copy a bundled skill into ./.claude/skills/<name>/.
|
|
1616
|
+
Omit name for an interactive multiselect (human),
|
|
1617
|
+
or copy all bundled skills (--all / --json /
|
|
1618
|
+
non-interactive).
|
|
1619
|
+
|
|
1620
|
+
Options:
|
|
1621
|
+
--all Install every bundled skill (skips the interactive picker).
|
|
1622
|
+
--force Overwrite an already-installed skill. Use after upgrading the
|
|
1623
|
+
engine to refresh vendored skills to the latest guidance.
|
|
1624
|
+
--json Emit machine-readable JSON only (implies non-interactive).
|
|
1625
|
+
-h, --help Show this help.
|
|
1626
|
+
|
|
1627
|
+
Examples:
|
|
1628
|
+
hogsend skills list
|
|
1629
|
+
hogsend skills list --json
|
|
1630
|
+
hogsend skills add
|
|
1631
|
+
hogsend skills add --all
|
|
1632
|
+
hogsend skills add hogsend-cli --force
|
|
1633
|
+
hogsend skills add --all --force # refresh everything after an upgrade
|
|
1634
|
+
|
|
1635
|
+
Tip: \`hogsend upgrade\` bumps the engine AND refreshes these skills in one step.`;
|
|
1636
|
+
function runList3(ctx) {
|
|
1637
|
+
const skills = listBundledSkills(process.cwd());
|
|
1638
|
+
if (ctx.json) {
|
|
1639
|
+
ctx.out.json({
|
|
1640
|
+
bundledSkillsDir: bundledSkillsDir(),
|
|
1641
|
+
installDir: installDir(process.cwd()),
|
|
1642
|
+
skills
|
|
1643
|
+
});
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} skills`);
|
|
1647
|
+
if (skills.length === 0) {
|
|
1648
|
+
ctx.out.note(
|
|
1649
|
+
"No bundled skills found in this package build.",
|
|
1650
|
+
"skills list"
|
|
1651
|
+
);
|
|
1652
|
+
ctx.out.outro("Nothing to install.");
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
ctx.out.table(
|
|
1656
|
+
skills.map((s) => ({
|
|
1657
|
+
name: s.name,
|
|
1658
|
+
installed: s.installed ? color.green("yes") : color.dim("no"),
|
|
1659
|
+
description: s.description.length > 60 ? `${s.description.slice(0, 57)}...` : s.description
|
|
1660
|
+
})),
|
|
1661
|
+
["name", "installed", "description"]
|
|
1662
|
+
);
|
|
1663
|
+
ctx.out.outro(
|
|
1664
|
+
`Install with ${color.cyan("hogsend skills add <name>")} (or ${color.cyan("hogsend skills add --all")}). Refresh after an engine upgrade with ${color.cyan("--force")}.`
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
async function runAdd(ctx, argv) {
|
|
1668
|
+
const { values, positionals } = parseArgs8({
|
|
1669
|
+
args: argv,
|
|
1670
|
+
allowPositionals: true,
|
|
1671
|
+
options: {
|
|
1672
|
+
all: { type: "boolean", default: false },
|
|
1673
|
+
force: { type: "boolean", default: false },
|
|
1674
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
if (values.help) {
|
|
1678
|
+
ctx.out.log(usage8);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const cwd = process.cwd();
|
|
1682
|
+
const bundled = listBundledSkills(cwd);
|
|
1683
|
+
if (bundled.length === 0) {
|
|
1684
|
+
ctx.out.fail("no bundled skills found in this package build");
|
|
1685
|
+
}
|
|
1686
|
+
const requested = positionals[0];
|
|
1687
|
+
const force = Boolean(values.force);
|
|
1688
|
+
let names;
|
|
1689
|
+
if (requested) {
|
|
1690
|
+
const match = bundled.find((s) => s.name === requested);
|
|
1691
|
+
if (!match) {
|
|
1692
|
+
ctx.out.fail(
|
|
1693
|
+
`unknown skill "${requested}". Available: ${bundled.map((s) => s.name).join(", ")}`
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
names = [requested];
|
|
1697
|
+
} else if (values.all) {
|
|
1698
|
+
names = bundled.map((s) => s.name);
|
|
1699
|
+
} else if (ctx.out.interactive) {
|
|
1700
|
+
const picked = bail(
|
|
1701
|
+
await multiselect({
|
|
1702
|
+
message: "Which skills do you want to install?",
|
|
1703
|
+
options: bundled.map((s) => ({
|
|
1704
|
+
value: s.name,
|
|
1705
|
+
label: s.name,
|
|
1706
|
+
hint: s.installed ? "installed" : void 0
|
|
1707
|
+
})),
|
|
1708
|
+
required: true
|
|
1709
|
+
})
|
|
1710
|
+
);
|
|
1711
|
+
names = picked;
|
|
1712
|
+
} else {
|
|
1713
|
+
names = bundled.map((s) => s.name);
|
|
1714
|
+
}
|
|
1715
|
+
const results = names.map(
|
|
1716
|
+
(name) => copySkill(name, cwd, force)
|
|
1717
|
+
);
|
|
1718
|
+
if (results.some((r) => r.installed)) {
|
|
1719
|
+
const installedNames = listBundledSkills(cwd).filter((s) => existsSync5(join5(installDir(cwd), s.name))).map((s) => s.name);
|
|
1720
|
+
writeSkillsStamp(cwd, installedNames);
|
|
1721
|
+
}
|
|
1722
|
+
if (ctx.json) {
|
|
1723
|
+
ctx.out.json({
|
|
1724
|
+
installDir: installDir(cwd),
|
|
1725
|
+
force,
|
|
1726
|
+
results
|
|
1727
|
+
});
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} skills add`);
|
|
1731
|
+
for (const r of results) {
|
|
1732
|
+
if (r.skipped) {
|
|
1733
|
+
ctx.out.log(
|
|
1734
|
+
`${color.yellow("skip")} ${r.name} ${color.dim("(already installed; use --force to overwrite)")}`
|
|
1735
|
+
);
|
|
1736
|
+
} else {
|
|
1737
|
+
ctx.out.log(`${color.green("\u2713")} ${r.name} ${color.dim(`-> ${r.path}`)}`);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
const installedCount = results.filter((r) => r.installed).length;
|
|
1741
|
+
const skippedCount = results.filter((r) => r.skipped).length;
|
|
1742
|
+
ctx.out.outro(
|
|
1743
|
+
`Installed ${installedCount} skill${installedCount === 1 ? "" : "s"}` + (skippedCount > 0 ? `, skipped ${skippedCount}.` : ".")
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
async function run8(ctx) {
|
|
1747
|
+
const sub = ctx.argv[0];
|
|
1748
|
+
switch (sub) {
|
|
1749
|
+
case "list":
|
|
1750
|
+
runList3(ctx);
|
|
1751
|
+
return;
|
|
1752
|
+
case "add":
|
|
1753
|
+
await runAdd(ctx, ctx.argv.slice(1));
|
|
1754
|
+
return;
|
|
1755
|
+
case void 0:
|
|
1756
|
+
case "-h":
|
|
1757
|
+
case "--help":
|
|
1758
|
+
ctx.out.log(usage8);
|
|
1759
|
+
return;
|
|
1760
|
+
default:
|
|
1761
|
+
ctx.out.fail(
|
|
1762
|
+
`unknown skills subcommand "${sub}". Use: list | add. See hogsend skills --help.`
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
var skillsCommand = {
|
|
1767
|
+
name: "skills",
|
|
1768
|
+
summary: "List + install bundled Claude Code skills into .claude/skills",
|
|
1769
|
+
usage: usage8,
|
|
1770
|
+
run: run8
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
// src/commands/stats.ts
|
|
1774
|
+
import { parseArgs as parseArgs9 } from "util";
|
|
1775
|
+
var usage9 = `hogsend stats [--json]
|
|
1776
|
+
|
|
1777
|
+
Show system-wide overview metrics from a running Hogsend instance.
|
|
1778
|
+
Wraps GET /v1/admin/metrics/overview.
|
|
1779
|
+
|
|
1780
|
+
Fields:
|
|
1781
|
+
totalContacts Live (non-deleted) contacts.
|
|
1782
|
+
activeJourneys Journey states currently active or waiting.
|
|
1783
|
+
emailsSent24h Emails sent in the last 24 hours.
|
|
1784
|
+
emailsSent7d Emails sent in the last 7 days.
|
|
1785
|
+
emailsSent30d Emails sent in the last 30 days.
|
|
1786
|
+
bounceRate30d Bounced / sent over the last 30 days (0..1).
|
|
1787
|
+
unsubscribeRate Unsubscribed / total preferences (0..1).
|
|
1788
|
+
|
|
1789
|
+
Options:
|
|
1790
|
+
--url <baseUrl> API base URL (default HOGSEND_API_URL or http://localhost:3002).
|
|
1791
|
+
--admin-key <key> Admin bearer key (default HOGSEND_ADMIN_KEY / ADMIN_API_KEY).
|
|
1792
|
+
--json Emit machine-readable JSON only.
|
|
1793
|
+
-h, --help Show this help.`;
|
|
1794
|
+
function pct(rate) {
|
|
1795
|
+
return `${(rate * 100).toFixed(2)}%`;
|
|
1796
|
+
}
|
|
1797
|
+
async function run9(ctx) {
|
|
1798
|
+
const { values } = parseArgs9({
|
|
1799
|
+
args: ctx.argv,
|
|
1800
|
+
allowPositionals: true,
|
|
1801
|
+
options: {
|
|
1802
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
if (values.help) {
|
|
1806
|
+
ctx.out.log(usage9);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
const metrics = await ctx.out.step(
|
|
1810
|
+
"Fetching overview metrics",
|
|
1811
|
+
() => ctx.http.get("/v1/admin/metrics/overview")
|
|
1812
|
+
);
|
|
1813
|
+
if (ctx.json) {
|
|
1814
|
+
ctx.out.json(metrics);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} stats`);
|
|
1818
|
+
ctx.out.kv(
|
|
1819
|
+
{
|
|
1820
|
+
"Total contacts": metrics.totalContacts,
|
|
1821
|
+
"Active journeys": metrics.activeJourneys,
|
|
1822
|
+
"Emails sent (24h)": metrics.emailsSent24h,
|
|
1823
|
+
"Emails sent (7d)": metrics.emailsSent7d,
|
|
1824
|
+
"Emails sent (30d)": metrics.emailsSent30d,
|
|
1825
|
+
"Bounce rate (30d)": pct(metrics.bounceRate30d),
|
|
1826
|
+
"Unsubscribe rate": pct(metrics.unsubscribeRate)
|
|
1827
|
+
},
|
|
1828
|
+
"Overview"
|
|
1829
|
+
);
|
|
1830
|
+
ctx.out.outro(color.dim(ctx.http.cfg.baseUrl));
|
|
1831
|
+
}
|
|
1832
|
+
var statsCommand = {
|
|
1833
|
+
name: "stats",
|
|
1834
|
+
summary: "Show system-wide overview metrics",
|
|
1835
|
+
usage: usage9,
|
|
1836
|
+
run: run9
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
// src/commands/studio.ts
|
|
1840
|
+
import { spawn } from "child_process";
|
|
1841
|
+
import { createReadStream, existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
|
|
1842
|
+
import { createServer } from "http";
|
|
1843
|
+
import { extname, join as join6, normalize, resolve, sep as sep3 } from "path";
|
|
1844
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1845
|
+
import { parseArgs as parseArgs10 } from "util";
|
|
1846
|
+
var usage10 = `hogsend studio [options]
|
|
1847
|
+
|
|
1848
|
+
Serve the bundled Hogsend Studio (the admin SPA) locally and open it in a
|
|
1849
|
+
browser. The Studio is a static single-page app; this command starts a tiny
|
|
1850
|
+
local web server for it on a port of your choosing.
|
|
1851
|
+
|
|
1852
|
+
By default the Studio talks to the API at the same origin it is served from,
|
|
1853
|
+
which won't be a running API here \u2014 so point it at your instance with
|
|
1854
|
+
--base-url (the SPA uses cookie auth, so the instance must allow CORS from the
|
|
1855
|
+
Studio origin, or you can simply open the Studio that the engine mounts at
|
|
1856
|
+
\`<instance>/studio\` instead).
|
|
1857
|
+
|
|
1858
|
+
Options:
|
|
1859
|
+
--port <n> Local port to serve on (default 3333).
|
|
1860
|
+
--base-url <url> API instance the Studio should call (injected at runtime).
|
|
1861
|
+
Omit to use same-origin (the local server, for static
|
|
1862
|
+
preview only).
|
|
1863
|
+
--open Open the Studio in your default browser after starting.
|
|
1864
|
+
--dist <path> Override the Studio dist directory (advanced).
|
|
1865
|
+
-h, --help Show this help.
|
|
1866
|
+
|
|
1867
|
+
Examples:
|
|
1868
|
+
hogsend studio --open
|
|
1869
|
+
hogsend studio --base-url https://api.example.com --open
|
|
1870
|
+
hogsend studio --port 4000`;
|
|
1871
|
+
function resolveStudioDist(distFlag) {
|
|
1872
|
+
const candidates = [];
|
|
1873
|
+
if (distFlag && distFlag.length > 0) {
|
|
1874
|
+
candidates.push(resolve(process.cwd(), distFlag));
|
|
1875
|
+
}
|
|
1876
|
+
candidates.push(fileURLToPath2(new URL("../studio", import.meta.url)));
|
|
1877
|
+
candidates.push(
|
|
1878
|
+
fileURLToPath2(new URL("../../studio/dist", import.meta.url)),
|
|
1879
|
+
fileURLToPath2(new URL("../../../studio/dist", import.meta.url))
|
|
1880
|
+
);
|
|
1881
|
+
candidates.push(resolve(process.cwd(), "packages/studio/dist"));
|
|
1882
|
+
for (const dir of candidates) {
|
|
1883
|
+
if (existsSync6(join6(dir, "index.html"))) {
|
|
1884
|
+
return dir;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return null;
|
|
1888
|
+
}
|
|
1889
|
+
var MIME = {
|
|
1890
|
+
".html": "text/html; charset=utf-8",
|
|
1891
|
+
".js": "text/javascript; charset=utf-8",
|
|
1892
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
1893
|
+
".css": "text/css; charset=utf-8",
|
|
1894
|
+
".json": "application/json; charset=utf-8",
|
|
1895
|
+
".svg": "image/svg+xml",
|
|
1896
|
+
".png": "image/png",
|
|
1897
|
+
".jpg": "image/jpeg",
|
|
1898
|
+
".jpeg": "image/jpeg",
|
|
1899
|
+
".gif": "image/gif",
|
|
1900
|
+
".ico": "image/x-icon",
|
|
1901
|
+
".woff": "font/woff",
|
|
1902
|
+
".woff2": "font/woff2",
|
|
1903
|
+
".ttf": "font/ttf",
|
|
1904
|
+
".map": "application/json; charset=utf-8"
|
|
1905
|
+
};
|
|
1906
|
+
function mimeFor(path) {
|
|
1907
|
+
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
1908
|
+
}
|
|
1909
|
+
function indexHtml(distPath, baseUrl) {
|
|
1910
|
+
const raw = readFileSync3(join6(distPath, "index.html"), "utf8");
|
|
1911
|
+
if (!baseUrl) return raw;
|
|
1912
|
+
const inject = `<script>window.__HOGSEND_STUDIO__=${JSON.stringify({
|
|
1913
|
+
baseUrl
|
|
1914
|
+
})};</script>`;
|
|
1915
|
+
if (raw.includes("</head>")) {
|
|
1916
|
+
return raw.replace("</head>", `${inject}</head>`);
|
|
1917
|
+
}
|
|
1918
|
+
return `${inject}${raw}`;
|
|
1919
|
+
}
|
|
1920
|
+
function openBrowser(url) {
|
|
1921
|
+
const platform = process.platform;
|
|
1922
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
1923
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1924
|
+
try {
|
|
1925
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
1926
|
+
child.on("error", () => {
|
|
1927
|
+
});
|
|
1928
|
+
child.unref();
|
|
1929
|
+
} catch {
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
async function run10(ctx) {
|
|
1933
|
+
const { values, positionals } = parseArgs10({
|
|
1934
|
+
args: ctx.argv,
|
|
1935
|
+
allowPositionals: true,
|
|
1936
|
+
strict: false,
|
|
1937
|
+
options: {
|
|
1938
|
+
port: { type: "string" },
|
|
1939
|
+
"base-url": { type: "string" },
|
|
1940
|
+
open: { type: "boolean", default: false },
|
|
1941
|
+
dist: { type: "string" },
|
|
1942
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
if (values.help) {
|
|
1946
|
+
ctx.out.log(usage10);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const port = Number(values.port ?? "3333");
|
|
1950
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1951
|
+
ctx.out.fail(`invalid --port "${values.port}" (expected 1-65535)`);
|
|
1952
|
+
}
|
|
1953
|
+
const baseUrl = typeof values["base-url"] === "string" ? values["base-url"] : void 0;
|
|
1954
|
+
const distPath = resolveStudioDist(
|
|
1955
|
+
typeof values.dist === "string" ? values.dist : positionals[0]
|
|
1956
|
+
);
|
|
1957
|
+
if (!distPath) {
|
|
1958
|
+
ctx.out.fail(
|
|
1959
|
+
"could not find a built Studio (dist/). Build it with `pnpm --filter @hogsend/studio build`, or pass --dist <path>."
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
const cleanBase = baseUrl ? baseUrl.replace(/\/+$/, "") : void 0;
|
|
1963
|
+
const index = indexHtml(distPath, cleanBase);
|
|
1964
|
+
const server = createServer((req, res) => {
|
|
1965
|
+
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
|
|
1966
|
+
const rel = urlPath.replace(/^\/studio/, "");
|
|
1967
|
+
if (rel === "" || rel === "/") {
|
|
1968
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
1969
|
+
res.end(index);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
const target = normalize(join6(distPath, rel));
|
|
1973
|
+
if (target !== distPath && !target.startsWith(distPath + sep3)) {
|
|
1974
|
+
res.writeHead(403);
|
|
1975
|
+
res.end("Forbidden");
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (existsSync6(target) && statSync2(target).isFile()) {
|
|
1979
|
+
res.writeHead(200, { "content-type": mimeFor(target) });
|
|
1980
|
+
createReadStream(target).pipe(res);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
1984
|
+
res.end(index);
|
|
1985
|
+
});
|
|
1986
|
+
await new Promise((resolveListen, reject) => {
|
|
1987
|
+
server.once("error", reject);
|
|
1988
|
+
server.listen(port, () => resolveListen());
|
|
1989
|
+
}).catch((err) => {
|
|
1990
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1991
|
+
ctx.out.fail(`could not start server on port ${port}: ${msg}`);
|
|
1992
|
+
});
|
|
1993
|
+
const localUrl = `http://localhost:${port}/studio/`;
|
|
1994
|
+
if (ctx.json) {
|
|
1995
|
+
ctx.out.json({
|
|
1996
|
+
url: localUrl,
|
|
1997
|
+
port,
|
|
1998
|
+
baseUrl: cleanBase ?? null,
|
|
1999
|
+
dist: distPath
|
|
2000
|
+
});
|
|
2001
|
+
} else {
|
|
2002
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} studio`);
|
|
2003
|
+
ctx.out.note(
|
|
2004
|
+
[
|
|
2005
|
+
`${color.green("\u25CF")} Studio serving at ${color.cyan(localUrl)}`,
|
|
2006
|
+
cleanBase ? color.dim(`API instance: ${cleanBase}`) : color.dim(
|
|
2007
|
+
"No --base-url set (same-origin / static preview). The API calls will hit this local server and fail \u2014 pass --base-url <instance>, or open <instance>/studio directly."
|
|
2008
|
+
),
|
|
2009
|
+
"",
|
|
2010
|
+
color.dim("First load shows a create-admin screen if no admin exists."),
|
|
2011
|
+
color.dim("Press Ctrl+C to stop.")
|
|
2012
|
+
].join("\n"),
|
|
2013
|
+
"Studio"
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
if (values.open) {
|
|
2017
|
+
openBrowser(localUrl);
|
|
2018
|
+
}
|
|
2019
|
+
await new Promise((resolveForever) => {
|
|
2020
|
+
const stop = () => {
|
|
2021
|
+
server.close(() => resolveForever());
|
|
2022
|
+
};
|
|
2023
|
+
process.on("SIGINT", stop);
|
|
2024
|
+
process.on("SIGTERM", stop);
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
var studioCommand = {
|
|
2028
|
+
name: "studio",
|
|
2029
|
+
summary: "Serve the bundled Hogsend Studio admin SPA locally",
|
|
2030
|
+
usage: usage10,
|
|
2031
|
+
run: run10
|
|
2032
|
+
};
|
|
2033
|
+
|
|
2034
|
+
// src/commands/upgrade.ts
|
|
2035
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2036
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4 } from "fs";
|
|
2037
|
+
import { join as join7 } from "path";
|
|
2038
|
+
import { parseArgs as parseArgs11 } from "util";
|
|
2039
|
+
import { confirm as confirm2 } from "@clack/prompts";
|
|
2040
|
+
var usage11 = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
|
|
2041
|
+
|
|
2042
|
+
Upgrade a scaffolded Hogsend app in one step:
|
|
2043
|
+
1. bump every @hogsend/* dependency to latest (or --to <version>), then
|
|
2044
|
+
2. refresh the vendored Claude Code skills in ./.claude/skills to match.
|
|
2045
|
+
|
|
2046
|
+
Run this after a new engine release so your app AND the agent guidance move
|
|
2047
|
+
together. Skills are version-stamped so \`hogsend doctor\` can warn when they
|
|
2048
|
+
fall behind.
|
|
2049
|
+
|
|
2050
|
+
Options:
|
|
2051
|
+
--cwd <dir> Project root to upgrade (defaults to the current directory).
|
|
2052
|
+
--pm <manager> Package manager (default: detected from the lockfile, else pnpm).
|
|
2053
|
+
--to <version> Target version for @hogsend/* deps (default: latest).
|
|
2054
|
+
--deps-only Bump dependencies only; don't touch skills.
|
|
2055
|
+
--skills-only Refresh skills only; don't touch dependencies.
|
|
2056
|
+
--yes, -y Skip the confirmation prompt. Implied by --json.
|
|
2057
|
+
--json Run non-interactively and emit a single JSON result.
|
|
2058
|
+
-h, --help Show this help.`;
|
|
2059
|
+
var VALID_PMS = ["pnpm", "npm", "yarn", "bun"];
|
|
2060
|
+
function detectPm(cwd) {
|
|
2061
|
+
if (existsSync7(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
2062
|
+
if (existsSync7(join7(cwd, "yarn.lock"))) return "yarn";
|
|
2063
|
+
if (existsSync7(join7(cwd, "bun.lockb")) || existsSync7(join7(cwd, "bun.lock")))
|
|
2064
|
+
return "bun";
|
|
2065
|
+
if (existsSync7(join7(cwd, "package-lock.json"))) return "npm";
|
|
2066
|
+
return "pnpm";
|
|
2067
|
+
}
|
|
2068
|
+
function hogsendDeps(cwd) {
|
|
2069
|
+
const pkg = JSON.parse(readFileSync4(join7(cwd, "package.json"), "utf8"));
|
|
2070
|
+
const all = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2071
|
+
return Object.keys(all).filter((n) => n.startsWith("@hogsend/")).sort();
|
|
2072
|
+
}
|
|
2073
|
+
function addArgs(pm, specs) {
|
|
2074
|
+
return [pm === "npm" ? "install" : "add", ...specs];
|
|
2075
|
+
}
|
|
2076
|
+
async function run11(ctx) {
|
|
2077
|
+
const { values } = parseArgs11({
|
|
2078
|
+
args: ctx.argv,
|
|
2079
|
+
allowPositionals: true,
|
|
2080
|
+
options: {
|
|
2081
|
+
cwd: { type: "string" },
|
|
2082
|
+
pm: { type: "string" },
|
|
2083
|
+
to: { type: "string" },
|
|
2084
|
+
"deps-only": { type: "boolean", default: false },
|
|
2085
|
+
"skills-only": { type: "boolean", default: false },
|
|
2086
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
2087
|
+
help: { type: "boolean", short: "h", default: false }
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
if (values.help) {
|
|
2091
|
+
ctx.out.log(usage11);
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
if (values["deps-only"] && values["skills-only"]) {
|
|
2095
|
+
ctx.out.fail("--deps-only and --skills-only are mutually exclusive.");
|
|
2096
|
+
}
|
|
2097
|
+
const cwd = values.cwd ?? process.cwd();
|
|
2098
|
+
if (!existsSync7(join7(cwd, "package.json"))) {
|
|
2099
|
+
ctx.out.fail(
|
|
2100
|
+
`no package.json in ${cwd} \u2014 run upgrade from a scaffolded Hogsend app (or pass --cwd).`
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
let pm;
|
|
2104
|
+
if (values.pm !== void 0) {
|
|
2105
|
+
if (!VALID_PMS.includes(values.pm)) {
|
|
2106
|
+
ctx.out.fail(
|
|
2107
|
+
`invalid --pm "${values.pm}". Expected one of: ${VALID_PMS.join(", ")}.`
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
pm = values.pm;
|
|
2111
|
+
} else {
|
|
2112
|
+
pm = detectPm(cwd);
|
|
2113
|
+
}
|
|
2114
|
+
const target = values.to ?? "latest";
|
|
2115
|
+
const doDeps = !values["skills-only"];
|
|
2116
|
+
const doSkills = !values["deps-only"];
|
|
2117
|
+
const deps = doDeps ? hogsendDeps(cwd) : [];
|
|
2118
|
+
if (doDeps && deps.length === 0) {
|
|
2119
|
+
ctx.out.fail(
|
|
2120
|
+
`no @hogsend/* dependencies found in ${join7(cwd, "package.json")}.`
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
const skipConfirm = ctx.json || values.yes;
|
|
2124
|
+
if (!ctx.json) {
|
|
2125
|
+
ctx.out.intro(
|
|
2126
|
+
`${color.bgMagenta(color.black(" hogsend "))} ${color.dim("upgrade")}`
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
if (ctx.out.interactive && !skipConfirm) {
|
|
2130
|
+
const plan = [
|
|
2131
|
+
doDeps ? `bump ${deps.length} @hogsend/* dep(s) to ${target} (${pm})` : null,
|
|
2132
|
+
doSkills ? "refresh .claude/skills" : null
|
|
2133
|
+
].filter(Boolean).join(" + ");
|
|
2134
|
+
const proceed = bail(
|
|
2135
|
+
await confirm2({ message: `Upgrade ${color.cyan(cwd)}: ${plan}?` })
|
|
2136
|
+
);
|
|
2137
|
+
if (!proceed) {
|
|
2138
|
+
ctx.out.outro(color.dim("Nothing changed."));
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
const results = [];
|
|
2143
|
+
if (doDeps) {
|
|
2144
|
+
const specs = deps.map((n) => `${n}@${target}`);
|
|
2145
|
+
const dep = await ctx.out.step(
|
|
2146
|
+
`Bumping @hogsend/* -> ${target} (${pm})`,
|
|
2147
|
+
async () => spawnSync3(pm, addArgs(pm, specs), {
|
|
2148
|
+
cwd,
|
|
2149
|
+
stdio: ctx.json ? "ignore" : "inherit",
|
|
2150
|
+
shell: process.platform === "win32"
|
|
2151
|
+
})
|
|
2152
|
+
);
|
|
2153
|
+
results.push({
|
|
2154
|
+
step: "deps",
|
|
2155
|
+
status: dep.status === 0 ? "ok" : "failed",
|
|
2156
|
+
detail: dep.status === 0 ? `${deps.join(", ")} -> ${target}` : `${pm} exited with code ${dep.status ?? "?"}`
|
|
2157
|
+
});
|
|
2158
|
+
} else {
|
|
2159
|
+
results.push({ step: "deps", status: "skipped", detail: "--skills-only" });
|
|
2160
|
+
}
|
|
2161
|
+
const depsFailed = results.some(
|
|
2162
|
+
(r) => r.step === "deps" && r.status === "failed"
|
|
2163
|
+
);
|
|
2164
|
+
if (!doSkills) {
|
|
2165
|
+
results.push({
|
|
2166
|
+
step: "skills",
|
|
2167
|
+
status: "skipped",
|
|
2168
|
+
detail: "--deps-only"
|
|
2169
|
+
});
|
|
2170
|
+
} else if (depsFailed) {
|
|
2171
|
+
results.push({
|
|
2172
|
+
step: "skills",
|
|
2173
|
+
status: "skipped",
|
|
2174
|
+
detail: "skipped \u2014 dependency bump failed; fix it then re-run"
|
|
2175
|
+
});
|
|
2176
|
+
} else {
|
|
2177
|
+
const bundled = listBundledSkills(cwd);
|
|
2178
|
+
const copied = bundled.map((s) => copySkill(s.name, cwd, true));
|
|
2179
|
+
writeSkillsStamp(
|
|
2180
|
+
cwd,
|
|
2181
|
+
bundled.map((s) => s.name)
|
|
2182
|
+
);
|
|
2183
|
+
results.push({
|
|
2184
|
+
step: "skills",
|
|
2185
|
+
status: "ok",
|
|
2186
|
+
detail: `refreshed ${copied.length} skill(s) -> ${installDir(cwd)}`
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
2190
|
+
const ok = failed.length === 0;
|
|
2191
|
+
if (ctx.json) {
|
|
2192
|
+
ctx.out.json({ ok, cwd, pm, target, steps: results });
|
|
2193
|
+
if (!ok) process.exit(1);
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
ctx.out.table(
|
|
2197
|
+
results.map((r) => ({
|
|
2198
|
+
step: r.step,
|
|
2199
|
+
status: r.status === "ok" ? color.green("ok") : r.status === "skipped" ? color.dim("skipped") : color.red("failed"),
|
|
2200
|
+
detail: r.detail
|
|
2201
|
+
})),
|
|
2202
|
+
["step", "status", "detail"]
|
|
2203
|
+
);
|
|
2204
|
+
if (!ok) {
|
|
2205
|
+
ctx.out.fail(
|
|
2206
|
+
`${failed.length} step(s) failed \u2014 see the table above. Fix and re-run hogsend upgrade.`
|
|
2207
|
+
);
|
|
2208
|
+
}
|
|
2209
|
+
ctx.out.outro(
|
|
2210
|
+
`${color.green("Upgraded.")} ${color.dim("Engine + agent skills are on the latest line.")}`
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
var upgradeCommand = {
|
|
2214
|
+
name: "upgrade",
|
|
2215
|
+
summary: "Bump @hogsend/* deps to latest + refresh vendored skills",
|
|
2216
|
+
usage: usage11,
|
|
2217
|
+
run: run11
|
|
2218
|
+
};
|
|
2219
|
+
|
|
2220
|
+
// src/commands/index.ts
|
|
2221
|
+
var commands = [
|
|
2222
|
+
doctorCommand,
|
|
2223
|
+
journeysCommand,
|
|
2224
|
+
contactsCommand,
|
|
2225
|
+
statsCommand,
|
|
2226
|
+
eventsCommand,
|
|
2227
|
+
studioCommand,
|
|
2228
|
+
setupCommand,
|
|
2229
|
+
skillsCommand,
|
|
2230
|
+
upgradeCommand,
|
|
2231
|
+
ejectCommand,
|
|
2232
|
+
patchCommand
|
|
2233
|
+
];
|
|
2234
|
+
|
|
2235
|
+
// src/lib/config.ts
|
|
2236
|
+
import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
|
|
2237
|
+
import { join as join8 } from "path";
|
|
2238
|
+
import { parseArgs as parseArgs12 } from "util";
|
|
2239
|
+
var DEFAULT_BASE_URL = "http://localhost:3002";
|
|
2240
|
+
function parseGlobalFlags(argv) {
|
|
2241
|
+
const { values, tokens } = parseArgs12({
|
|
2242
|
+
args: argv,
|
|
2243
|
+
allowPositionals: true,
|
|
2244
|
+
strict: false,
|
|
2245
|
+
tokens: true,
|
|
2246
|
+
options: {
|
|
2247
|
+
url: { type: "string" },
|
|
2248
|
+
"admin-key": { type: "string" },
|
|
2249
|
+
json: { type: "boolean", default: false },
|
|
2250
|
+
help: { type: "boolean", short: "h", default: false }
|
|
2251
|
+
}
|
|
2252
|
+
});
|
|
2253
|
+
const owned = /* @__PURE__ */ new Set(["url", "admin-key", "json", "help", "h"]);
|
|
2254
|
+
const rest = [];
|
|
2255
|
+
for (const token of tokens) {
|
|
2256
|
+
if (token.kind === "positional") {
|
|
2257
|
+
rest.push(token.value);
|
|
2258
|
+
} else if (token.kind === "option") {
|
|
2259
|
+
if (owned.has(token.name)) continue;
|
|
2260
|
+
rest.push(token.rawName);
|
|
2261
|
+
if (token.value !== void 0 && !token.inlineValue) {
|
|
2262
|
+
rest.push(token.value);
|
|
2263
|
+
} else if (token.inlineValue && token.value !== void 0) {
|
|
2264
|
+
rest[rest.length - 1] = `${token.rawName}=${token.value}`;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return {
|
|
2269
|
+
url: typeof values.url === "string" ? values.url : void 0,
|
|
2270
|
+
adminKey: typeof values["admin-key"] === "string" ? values["admin-key"] : void 0,
|
|
2271
|
+
json: values.json === true,
|
|
2272
|
+
help: values.help === true,
|
|
2273
|
+
rest
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
function loadDotEnv(cwd = process.cwd()) {
|
|
2277
|
+
const out = {};
|
|
2278
|
+
const file = join8(cwd, ".env");
|
|
2279
|
+
if (!existsSync8(file)) return out;
|
|
2280
|
+
let raw;
|
|
2281
|
+
try {
|
|
2282
|
+
raw = readFileSync5(file, "utf8");
|
|
2283
|
+
} catch {
|
|
2284
|
+
return out;
|
|
2285
|
+
}
|
|
2286
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
2287
|
+
const line = rawLine.trim();
|
|
2288
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
2289
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length) : line;
|
|
2290
|
+
const eq = withoutExport.indexOf("=");
|
|
2291
|
+
if (eq === -1) continue;
|
|
2292
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
2293
|
+
if (key === "") continue;
|
|
2294
|
+
let value = withoutExport.slice(eq + 1).trim();
|
|
2295
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2296
|
+
value = value.slice(1, -1);
|
|
2297
|
+
}
|
|
2298
|
+
out[key] = value;
|
|
2299
|
+
}
|
|
2300
|
+
return out;
|
|
2301
|
+
}
|
|
2302
|
+
function resolveConfig(flags, cwd = process.cwd()) {
|
|
2303
|
+
const dotenv = loadDotEnv(cwd);
|
|
2304
|
+
const baseUrlRaw = flags.url ?? process.env.HOGSEND_API_URL ?? dotenv.HOGSEND_API_URL ?? DEFAULT_BASE_URL;
|
|
2305
|
+
const adminKey = flags.adminKey ?? process.env.HOGSEND_ADMIN_KEY ?? process.env.ADMIN_API_KEY ?? dotenv.HOGSEND_ADMIN_KEY ?? dotenv.ADMIN_API_KEY;
|
|
2306
|
+
return {
|
|
2307
|
+
baseUrl: baseUrlRaw.replace(/\/+$/, ""),
|
|
2308
|
+
adminKey: adminKey && adminKey.length > 0 ? adminKey : void 0
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// src/bin.ts
|
|
2313
|
+
function version() {
|
|
2314
|
+
try {
|
|
2315
|
+
const require2 = createRequire3(import.meta.url);
|
|
2316
|
+
const pkg = require2("../package.json");
|
|
2317
|
+
return pkg.version ?? "0.0.0";
|
|
2318
|
+
} catch {
|
|
2319
|
+
return "0.0.0";
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
function rootUsage() {
|
|
2323
|
+
const longest = commands.reduce((n, c) => Math.max(n, c.name.length), 0);
|
|
2324
|
+
const list = commands.map((c) => ` ${color.cyan(c.name.padEnd(longest))} ${c.summary}`).join("\n");
|
|
2325
|
+
return `${color.bold("hogsend")} \u2014 the agent-native Hogsend CLI
|
|
2326
|
+
|
|
2327
|
+
${color.dim("Usage:")} hogsend <command> [options]
|
|
2328
|
+
|
|
2329
|
+
${color.dim("Commands:")}
|
|
2330
|
+
${list}
|
|
2331
|
+
|
|
2332
|
+
${color.dim("Global options:")}
|
|
2333
|
+
--url <baseUrl> Target instance (default HOGSEND_API_URL or http://localhost:3002)
|
|
2334
|
+
--admin-key <key> Admin bearer token (default HOGSEND_ADMIN_KEY / ADMIN_API_KEY)
|
|
2335
|
+
--json Emit machine-readable JSON only (for agents)
|
|
2336
|
+
-h, --help Show help (use after a command for command help)
|
|
2337
|
+
-v, --version Show version
|
|
2338
|
+
|
|
2339
|
+
Run ${color.cyan("hogsend <command> --help")} for command-specific options.`;
|
|
2340
|
+
}
|
|
2341
|
+
function findCommand(name) {
|
|
2342
|
+
return commands.find((c) => c.name === name);
|
|
2343
|
+
}
|
|
2344
|
+
async function main() {
|
|
2345
|
+
const argv = process.argv.slice(2);
|
|
2346
|
+
const [token, ...afterToken] = argv;
|
|
2347
|
+
if (token === "-v" || token === "--version") {
|
|
2348
|
+
process.stdout.write(`${version()}
|
|
2349
|
+
`);
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
if (!token || token === "-h" || token === "--help") {
|
|
2353
|
+
process.stdout.write(`${rootUsage()}
|
|
2354
|
+
`);
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
const command = findCommand(token);
|
|
2358
|
+
if (!command) {
|
|
2359
|
+
process.stderr.write(
|
|
2360
|
+
`${color.red("error")} unknown command "${token}"
|
|
2361
|
+
|
|
2362
|
+
${rootUsage()}
|
|
2363
|
+
`
|
|
2364
|
+
);
|
|
2365
|
+
process.exit(1);
|
|
2366
|
+
}
|
|
2367
|
+
const flags = parseGlobalFlags(afterToken);
|
|
2368
|
+
const out = createOutput({ json: flags.json });
|
|
2369
|
+
if (flags.help) {
|
|
2370
|
+
out.log(command.usage);
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
const cfg = resolveConfig(flags);
|
|
2374
|
+
const http = createAdminClient(cfg);
|
|
2375
|
+
await command.run({
|
|
2376
|
+
argv: flags.rest,
|
|
2377
|
+
cfg,
|
|
2378
|
+
http,
|
|
2379
|
+
out,
|
|
2380
|
+
json: flags.json
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
main().catch((error) => {
|
|
2384
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2385
|
+
if (process.argv.includes("--json")) {
|
|
2386
|
+
process.stdout.write(`${JSON.stringify({ error: msg })}
|
|
2387
|
+
`);
|
|
2388
|
+
} else {
|
|
2389
|
+
process.stderr.write(`${color.red("error")} ${msg}
|
|
2390
|
+
`);
|
|
2391
|
+
}
|
|
229
2392
|
process.exit(1);
|
|
230
2393
|
});
|
|
231
2394
|
//# sourceMappingURL=bin.js.map
|