@veleads/gtm 0.4.0 → 0.7.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/CHANGELOG.md +52 -0
- package/README.md +13 -2
- package/dist/cli.js +1250 -271
- package/kit/AGENTS.md +48 -22
- package/kit/guides/github.md +60 -0
- package/kit/schemas/account.schema.json +0 -5
- package/kit/schemas/campaign-config.schema.json +30 -0
- package/kit/schemas/contact.schema.json +0 -5
- package/kit/schemas/icp.schema.json +97 -8
- package/kit/schemas/lead.schema.json +0 -5
- package/kit/schemas/project.schema.json +24 -3
- package/kit/schemas/signal.schema.json +0 -5
- package/kit/skills/00-intake/SKILL.md +31 -15
- package/kit/skills/01-icp/SKILL.md +88 -33
- package/kit/skills/02-tam/SKILL.md +13 -8
- package/kit/skills/05-copy/SKILL.md +9 -3
- package/kit/skills/07-launch/SKILL.md +1 -1
- package/kit/skills/research/SKILL.md +62 -0
- package/kit/stack/docker-compose.yml +66 -0
- package/kit/stack/init/01-hatchet-db.sql +2 -0
- package/kit/templates/campaign/config.yaml +3 -0
- package/kit/templates/project/icp.md +44 -8
- package/kit/templates/project/icp.yaml +13 -4
- package/kit/templates/project/intake.md +26 -11
- package/kit/templates/project/project.yaml +8 -4
- package/kit/templates/project/research.md +45 -0
- package/kit/templates/repo/README.md +5 -0
- package/kit/templates/repo/env.example +3 -3
- package/kit/templates/repo/github/workflows/gtm.yml +5 -2
- package/package.json +4 -2
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,8 @@ var IsoDate = z.iso.date();
|
|
|
16
16
|
var IsoDateTime = z.iso.datetime({ offset: true });
|
|
17
17
|
var Domain = z.string().regex(/^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$/, "bare lowercase domain, e.g. acme.com");
|
|
18
18
|
var Tier = z.enum(["A", "B", "C"]);
|
|
19
|
+
var GitHubHandle = z.string().regex(/^@[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/, "GitHub handle like @username");
|
|
20
|
+
var isGitHubHandle = (value) => !!value && GitHubHandle.safeParse(value).success;
|
|
19
21
|
var Stage = z.enum([
|
|
20
22
|
"intake",
|
|
21
23
|
"icp",
|
|
@@ -33,7 +35,8 @@ var Approval = z.object({
|
|
|
33
35
|
approved_by: z.string().min(1).nullable(),
|
|
34
36
|
approved_at: IsoDate.nullable(),
|
|
35
37
|
notes: z.string().optional(),
|
|
36
|
-
content_hash: z.string().regex(/^sha256:[0-9a-f]{16}$/).optional().describe("Fingerprint of the approved content, written by `gtm approve`. Never edit by hand.")
|
|
38
|
+
content_hash: z.string().regex(/^sha256:[0-9a-f]{16}$/).optional().describe("Fingerprint of the approved content, written by `gtm approve`. Never edit by hand."),
|
|
39
|
+
approved_via: z.string().url().optional().describe("Pull request whose review is the approval (GitHub mode). Written by `gtm approve --pr`.")
|
|
37
40
|
}).refine(
|
|
38
41
|
(a) => a.status !== "approved" || a.approved_by !== null && a.approved_at !== null,
|
|
39
42
|
"approved requires approved_by and approved_at"
|
|
@@ -63,26 +66,43 @@ var ProjectFile = z2.object({
|
|
|
63
66
|
status: z2.enum(["onboarding", "active", "paused", "closed"]),
|
|
64
67
|
started_at: IsoDate,
|
|
65
68
|
owners: z2.object({
|
|
66
|
-
gtm_engineer: z2.string().min(1),
|
|
67
|
-
sdrs: z2.array(z2.string().min(1)),
|
|
68
|
-
approver: z2.string().min(1).describe("
|
|
69
|
+
gtm_engineer: z2.string().min(1).describe("GitHub handle (@user) or name"),
|
|
70
|
+
sdrs: z2.array(z2.string().min(1)).describe("GitHub handles (@user) or names"),
|
|
71
|
+
approver: z2.string().min(1).describe("Signs off ICP and copy. A GitHub handle (@user) makes approvals PR reviews enforced by GitHub; a name records them by name")
|
|
69
72
|
}),
|
|
70
73
|
regions: z2.array(z2.string().min(2)).min(1).describe("Target regions, e.g. EU, UK, US"),
|
|
71
|
-
features: z2.record(z2.string(), z2.boolean()).default({}).describe("Opt-in core features, see FEATURES")
|
|
74
|
+
features: z2.record(z2.string(), z2.boolean()).default({}).describe("Opt-in core features, see FEATURES"),
|
|
75
|
+
tracker: z2.object({ kind: z2.enum(["github", "none"]) }).default({ kind: "none" }).describe("Where `gtm track` mirrors stages and gates. Git files stay the source of truth")
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
// ../schemas/src/icp.ts
|
|
75
79
|
import { z as z3 } from "zod";
|
|
80
|
+
var FirmographicFilters = z3.object({
|
|
81
|
+
industries: z3.array(z3.string()).optional(),
|
|
82
|
+
employee_count: IntRange.optional(),
|
|
83
|
+
revenue_usd: IntRange.optional(),
|
|
84
|
+
hq_countries: z3.array(z3.string().length(2)).optional().describe("ISO 3166-1 alpha-2"),
|
|
85
|
+
technologies_any: z3.array(z3.string()).optional(),
|
|
86
|
+
technologies_none: z3.array(z3.string()).optional(),
|
|
87
|
+
keywords_any: z3.array(z3.string()).optional(),
|
|
88
|
+
keywords_none: z3.array(z3.string()).optional()
|
|
89
|
+
});
|
|
76
90
|
var Firmographics = z3.object({
|
|
77
91
|
industries: z3.array(z3.string()).default([]),
|
|
78
92
|
employee_count: IntRange.optional(),
|
|
79
93
|
revenue_usd: IntRange.optional(),
|
|
80
|
-
hq_countries: z3.array(z3.string().length(2)).default([])
|
|
94
|
+
hq_countries: z3.array(z3.string().length(2)).default([]),
|
|
81
95
|
technologies_any: z3.array(z3.string()).default([]),
|
|
82
96
|
technologies_none: z3.array(z3.string()).default([]),
|
|
83
97
|
keywords_any: z3.array(z3.string()).default([]),
|
|
84
98
|
keywords_none: z3.array(z3.string()).default([])
|
|
85
99
|
});
|
|
100
|
+
var IcpSegmentSpec = z3.object({
|
|
101
|
+
id: Slug,
|
|
102
|
+
description: z3.string().min(1),
|
|
103
|
+
firmographics: FirmographicFilters.default({}),
|
|
104
|
+
personas: z3.array(Slug).min(1).optional().describe("Restrict to these persona ids; default: all personas")
|
|
105
|
+
});
|
|
86
106
|
var IcpSegment = z3.object({
|
|
87
107
|
id: Slug,
|
|
88
108
|
description: z3.string().min(1),
|
|
@@ -96,11 +116,22 @@ var IcpPersona = z3.object({
|
|
|
96
116
|
seniorities: z3.array(z3.enum(["c_level", "vp", "head", "director", "manager", "senior", "entry"])).default([]),
|
|
97
117
|
departments: z3.array(z3.string()).default([])
|
|
98
118
|
});
|
|
119
|
+
var FILTER_KEYS = Object.keys(FirmographicFilters.shape);
|
|
120
|
+
function mergeFilters(core, segment) {
|
|
121
|
+
const merged = {};
|
|
122
|
+
for (const key of FILTER_KEYS) {
|
|
123
|
+
const value = segment?.[key] !== void 0 ? segment[key] : core?.[key];
|
|
124
|
+
if (value !== void 0) merged[key] = value;
|
|
125
|
+
}
|
|
126
|
+
return Firmographics.parse(merged);
|
|
127
|
+
}
|
|
128
|
+
var hasAnyFilter = (f) => f.industries.length + f.hq_countries.length + f.technologies_any.length + f.keywords_any.length > 0 || !!f.employee_count || !!f.revenue_usd;
|
|
99
129
|
var IcpFile = z3.object({
|
|
100
130
|
version: z3.literal(FILE_VERSIONS.icp),
|
|
101
131
|
project_id: Slug,
|
|
102
132
|
approval: Approval,
|
|
103
|
-
|
|
133
|
+
core: z3.object({ firmographics: FirmographicFilters.default({}) }).optional().describe("Profile every segment shares. Segments only add or replace what differs"),
|
|
134
|
+
segments: z3.array(IcpSegmentSpec).min(1),
|
|
104
135
|
personas: z3.array(IcpPersona).min(1),
|
|
105
136
|
exclusions: z3.object({
|
|
106
137
|
domains: z3.array(Domain).default([]),
|
|
@@ -113,7 +144,39 @@ var IcpFile = z3.object({
|
|
|
113
144
|
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
114
145
|
if (dupes.length) ctx.addIssue({ code: "custom", path: [key], message: `duplicate ids: ${dupes.join(", ")}` });
|
|
115
146
|
}
|
|
147
|
+
const personaIds = new Set(icp.personas.map((p) => p.id));
|
|
148
|
+
icp.segments.forEach((s, i) => {
|
|
149
|
+
for (const p of s.personas ?? []) {
|
|
150
|
+
if (!personaIds.has(p)) ctx.addIssue({ code: "custom", path: ["segments", i, "personas"], message: `unknown persona "${p}"` });
|
|
151
|
+
}
|
|
152
|
+
let resolved;
|
|
153
|
+
try {
|
|
154
|
+
resolved = mergeFilters(icp.core?.firmographics, s.firmographics);
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!hasAnyFilter(resolved)) {
|
|
159
|
+
ctx.addIssue({ code: "custom", path: ["segments", i, "firmographics"], message: `segment "${s.id}" has no filters (core + segment): it would match every company` });
|
|
160
|
+
}
|
|
161
|
+
const range = resolved.employee_count;
|
|
162
|
+
if (range?.min !== void 0 && range.max !== void 0 && range.min > range.max) {
|
|
163
|
+
ctx.addIssue({ code: "custom", path: ["segments", i, "firmographics", "employee_count"], message: `segment "${s.id}" resolves to min > max` });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
116
166
|
});
|
|
167
|
+
function resolveIcp(icp) {
|
|
168
|
+
const allPersonas = icp.personas.map((p) => p.id);
|
|
169
|
+
return {
|
|
170
|
+
exclusions: icp.exclusions,
|
|
171
|
+
segments: icp.segments.map((s) => ({
|
|
172
|
+
id: s.id,
|
|
173
|
+
description: s.description,
|
|
174
|
+
firmographics: mergeFilters(icp.core?.firmographics, s.firmographics),
|
|
175
|
+
personas: s.personas ?? allPersonas,
|
|
176
|
+
overrides: FILTER_KEYS.filter((k) => s.firmographics?.[k] !== void 0)
|
|
177
|
+
}))
|
|
178
|
+
};
|
|
179
|
+
}
|
|
117
180
|
|
|
118
181
|
// ../schemas/src/signals-config.ts
|
|
119
182
|
import { z as z4 } from "zod";
|
|
@@ -148,6 +211,10 @@ var Enrichment = z5.object({
|
|
|
148
211
|
max_contacts_per_account: z5.number().int().min(1).max(10),
|
|
149
212
|
cache_ttl_days: z5.number().int().positive()
|
|
150
213
|
});
|
|
214
|
+
var Tam = z5.object({
|
|
215
|
+
source: Slug.describe("Account source adapter (mock-accounts for local proofs; real providers in .gtm/providers/)"),
|
|
216
|
+
max_accounts_per_segment: z5.number().int().positive().max(1e5).default(500)
|
|
217
|
+
});
|
|
151
218
|
var Budget = z5.object({
|
|
152
219
|
currency: z5.literal("USD"),
|
|
153
220
|
max_total: z5.number().positive().describe("Hard cap for this campaign; jobs stop when reached"),
|
|
@@ -194,6 +261,7 @@ var CampaignConfig = z5.object({
|
|
|
194
261
|
owner: z5.string().min(1),
|
|
195
262
|
stage: Stage.describe("The stage currently being worked on"),
|
|
196
263
|
tam_id: Slug,
|
|
264
|
+
tam: Tam.optional().describe("How `gtm job tam` builds the TAM"),
|
|
197
265
|
segments: z5.array(Slug).min(1).describe("Segment ids from icp.yaml"),
|
|
198
266
|
personas: z5.array(Slug).min(1).describe("Persona ids from icp.yaml"),
|
|
199
267
|
enrichment: Enrichment,
|
|
@@ -212,7 +280,6 @@ var CampaignConfig = z5.object({
|
|
|
212
280
|
// ../schemas/src/account.ts
|
|
213
281
|
import { z as z6 } from "zod";
|
|
214
282
|
var Account = z6.object({
|
|
215
|
-
project_id: Slug,
|
|
216
283
|
tam_id: Slug,
|
|
217
284
|
domain: Domain,
|
|
218
285
|
name: z6.string().min(1),
|
|
@@ -229,7 +296,6 @@ var Account = z6.object({
|
|
|
229
296
|
import { z as z7 } from "zod";
|
|
230
297
|
var EmailStatus = z7.enum(["valid", "catch_all", "invalid", "unknown", "not_found"]);
|
|
231
298
|
var Contact = z7.object({
|
|
232
|
-
project_id: Slug,
|
|
233
299
|
account_domain: Domain,
|
|
234
300
|
first_name: z7.string().min(1),
|
|
235
301
|
last_name: z7.string().min(1),
|
|
@@ -246,7 +312,6 @@ var Contact = z7.object({
|
|
|
246
312
|
// ../schemas/src/signal.ts
|
|
247
313
|
import { z as z8 } from "zod";
|
|
248
314
|
var SignalEvent = z8.object({
|
|
249
|
-
project_id: Slug,
|
|
250
315
|
signal_key: Slug,
|
|
251
316
|
account_domain: Domain,
|
|
252
317
|
contact_linkedin_url: z8.string().url().nullable(),
|
|
@@ -259,7 +324,6 @@ var SignalEvent = z8.object({
|
|
|
259
324
|
// ../schemas/src/lead.ts
|
|
260
325
|
import { z as z9 } from "zod";
|
|
261
326
|
var Lead = z9.object({
|
|
262
|
-
project_id: Slug,
|
|
263
327
|
campaign_id: Slug,
|
|
264
328
|
run_id: z9.uuid(),
|
|
265
329
|
email: z9.email(),
|
|
@@ -343,10 +407,155 @@ function copyContentHash(markdown) {
|
|
|
343
407
|
return `sha256:${createHash("sha256").update(normalized).digest("hex").slice(0, 16)}`;
|
|
344
408
|
}
|
|
345
409
|
|
|
410
|
+
// ../schemas/src/jobs.ts
|
|
411
|
+
import { z as z10 } from "zod";
|
|
412
|
+
var JOB_NAMES = { tam: "gtm-tam" };
|
|
413
|
+
var JobContext = z10.object({
|
|
414
|
+
project_id: Slug,
|
|
415
|
+
campaign_id: Slug,
|
|
416
|
+
triggered_by: z10.string().min(1),
|
|
417
|
+
core_version: z10.string().min(1),
|
|
418
|
+
git_sha: z10.string().nullable(),
|
|
419
|
+
config_hash: z10.string().min(1)
|
|
420
|
+
});
|
|
421
|
+
var TamJobInput = z10.object({
|
|
422
|
+
ctx: JobContext,
|
|
423
|
+
tam_id: Slug,
|
|
424
|
+
source: Slug.describe("Account source adapter, e.g. mock-accounts"),
|
|
425
|
+
max_accounts_per_segment: z10.number().int().positive().max(1e5),
|
|
426
|
+
segments: z10.array(IcpSegment).min(1),
|
|
427
|
+
exclusions: z10.object({ domains: z10.array(Domain), industries: z10.array(z10.string()), hq_countries: z10.array(z10.string()) }),
|
|
428
|
+
budget_usd: z10.number().nonnegative()
|
|
429
|
+
});
|
|
430
|
+
var TamJobResult = z10.object({
|
|
431
|
+
run_id: z10.uuid(),
|
|
432
|
+
segments: z10.array(z10.object({
|
|
433
|
+
segment_id: Slug,
|
|
434
|
+
found: z10.number().int(),
|
|
435
|
+
inserted: z10.number().int(),
|
|
436
|
+
updated: z10.number().int(),
|
|
437
|
+
excluded: z10.number().int(),
|
|
438
|
+
reachable: z10.number().int(),
|
|
439
|
+
active: z10.number().int()
|
|
440
|
+
})),
|
|
441
|
+
totals: z10.object({ tam: z10.number().int(), reachable: z10.number().int(), active: z10.number().int() }),
|
|
442
|
+
cost_usd: z10.number().nonnegative()
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// src/commands/approve.ts
|
|
446
|
+
import { parse } from "yaml";
|
|
447
|
+
|
|
448
|
+
// src/github.ts
|
|
449
|
+
import { spawnSync } from "child_process";
|
|
450
|
+
function repoFromGit(root2) {
|
|
451
|
+
const res = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root2, encoding: "utf8" });
|
|
452
|
+
return res.status === 0 ? parseRepoUrl(res.stdout.trim()) : null;
|
|
453
|
+
}
|
|
454
|
+
function parseRepoUrl(url) {
|
|
455
|
+
const m = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
456
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
457
|
+
}
|
|
458
|
+
function githubToken() {
|
|
459
|
+
const env = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
460
|
+
if (env) return env;
|
|
461
|
+
const gh = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
462
|
+
return gh.status === 0 && gh.stdout.trim() ? gh.stdout.trim() : null;
|
|
463
|
+
}
|
|
464
|
+
var login = (handle) => handle.replace(/^@/, "").toLowerCase();
|
|
465
|
+
var GitHubError = class extends Error {
|
|
466
|
+
constructor(status2, message) {
|
|
467
|
+
super(message);
|
|
468
|
+
this.status = status2;
|
|
469
|
+
this.name = "GitHubError";
|
|
470
|
+
}
|
|
471
|
+
status;
|
|
472
|
+
};
|
|
473
|
+
var GitHubApi = class _GitHubApi {
|
|
474
|
+
constructor(repo, token, fetchImpl = fetch) {
|
|
475
|
+
this.repo = repo;
|
|
476
|
+
this.token = token;
|
|
477
|
+
this.fetchImpl = fetchImpl;
|
|
478
|
+
}
|
|
479
|
+
repo;
|
|
480
|
+
token;
|
|
481
|
+
fetchImpl;
|
|
482
|
+
/** Resolves repo + token from the working copy and environment; null with a reason if unavailable. */
|
|
483
|
+
static fromRepo(root2, fetchImpl = fetch) {
|
|
484
|
+
const repo = repoFromGit(root2);
|
|
485
|
+
if (!repo) return "no GitHub remote: `git remote get-url origin` must point to github.com";
|
|
486
|
+
const token = githubToken();
|
|
487
|
+
if (!token) return "no GitHub token: run `gh auth login`, or set GH_TOKEN / GITHUB_TOKEN";
|
|
488
|
+
return new _GitHubApi(repo, token, fetchImpl);
|
|
489
|
+
}
|
|
490
|
+
/** Path relative to /repos/{owner}/{repo} when it starts with "repo:", else absolute (e.g. /user). */
|
|
491
|
+
async request(method, path, body) {
|
|
492
|
+
const url = path.startsWith("repo:") ? `https://api.github.com/repos/${this.repo.owner}/${this.repo.repo}${path.slice(5)}` : `https://api.github.com${path}`;
|
|
493
|
+
const res = await this.fetchImpl(url, {
|
|
494
|
+
method,
|
|
495
|
+
headers: {
|
|
496
|
+
Accept: "application/vnd.github+json",
|
|
497
|
+
Authorization: `Bearer ${this.token}`,
|
|
498
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
499
|
+
...body ? { "Content-Type": "application/json" } : {}
|
|
500
|
+
},
|
|
501
|
+
body: body ? JSON.stringify(body) : void 0
|
|
502
|
+
});
|
|
503
|
+
if (!res.ok) throw new GitHubError(res.status, `GitHub ${method} ${path.replace(/^repo:/, "")}: ${res.status} ${await res.text()}`);
|
|
504
|
+
return res.status === 204 ? void 0 : await res.json();
|
|
505
|
+
}
|
|
506
|
+
/** Blob URL for a repo file on the default branch, for links in issue bodies. */
|
|
507
|
+
fileUrl(path) {
|
|
508
|
+
return `https://github.com/${this.repo.owner}/${this.repo.repo}/blob/HEAD/${path}`;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// src/github-approval.ts
|
|
513
|
+
function prNumber(ref) {
|
|
514
|
+
const m = ref.match(/(?:^#?|\/pull\/)(\d+)\/?$/);
|
|
515
|
+
return m ? Number(m[1]) : null;
|
|
516
|
+
}
|
|
517
|
+
function latestStateBy(reviews, approver) {
|
|
518
|
+
return reviews.filter((r) => r.user && login(r.user.login) === login(approver) && r.state !== "COMMENTED").at(-1);
|
|
519
|
+
}
|
|
520
|
+
async function verifyPrApproval(api, ref, approver, path) {
|
|
521
|
+
const n = prNumber(ref);
|
|
522
|
+
if (!n) return `--pr must be a pull request number or URL (got "${ref}")`;
|
|
523
|
+
const pr = await api.request("GET", `repo:/pulls/${n}`);
|
|
524
|
+
const files = await api.request("GET", `repo:/pulls/${n}/files?per_page=100`);
|
|
525
|
+
if (!files.some((f) => f.filename === path)) return `PR #${n} does not change ${path}`;
|
|
526
|
+
if (login(pr.user.login) === login(approver)) {
|
|
527
|
+
return `PR #${n} was opened by ${approver}, and GitHub doesn't let authors approve their own PRs. The PR must be opened by someone else (e.g. the GTM engineer or the agent's account) and approved by ${approver}.`;
|
|
528
|
+
}
|
|
529
|
+
const reviews = await api.request("GET", `repo:/pulls/${n}/reviews?per_page=100`);
|
|
530
|
+
const review = latestStateBy(reviews, approver);
|
|
531
|
+
if (!review || review.state !== "APPROVED") return `${approver} has not approved PR #${n} (latest review: ${review?.state ?? "none"})`;
|
|
532
|
+
if (review.commit_id !== pr.head.sha) return `${approver} approved an older commit of PR #${n}; the PR changed since. Ask for a fresh approval.`;
|
|
533
|
+
const file = await api.request("GET", `repo:/contents/${path}?ref=${pr.head.sha}`);
|
|
534
|
+
return {
|
|
535
|
+
prUrl: pr.html_url,
|
|
536
|
+
reviewer: `@${review.user.login}`,
|
|
537
|
+
approvedAt: review.submitted_at.slice(0, 10),
|
|
538
|
+
approvedContent: Buffer.from(file.content, file.encoding).toString("utf8")
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
async function verifyRecordedApproval(api, prUrl, approver, path, pinnedHash, hashOf) {
|
|
542
|
+
const n = prNumber(prUrl);
|
|
543
|
+
if (!n) return `approved_via is not a pull request URL: ${prUrl}`;
|
|
544
|
+
if (!prUrl.includes(`/${api.repo.owner}/${api.repo.repo}/pull/`)) return `approved_via points to another repository: ${prUrl}`;
|
|
545
|
+
const reviews = await api.request("GET", `repo:/pulls/${n}/reviews?per_page=100`);
|
|
546
|
+
const review = latestStateBy(reviews, approver);
|
|
547
|
+
if (review?.state !== "APPROVED") return `${approver}'s latest review on PR #${n} is ${review?.state ?? "missing"}, not APPROVED`;
|
|
548
|
+
const file = await api.request("GET", `repo:/contents/${path}?ref=${review.commit_id}`);
|
|
549
|
+
const reviewed = hashOf(Buffer.from(file.content, file.encoding).toString("utf8"));
|
|
550
|
+
return reviewed === pinnedHash ? true : `${path} at the commit ${approver} approved (${review.commit_id.slice(0, 7)}) has content ${reviewed}, but ${pinnedHash ?? "no hash"} is pinned`;
|
|
551
|
+
}
|
|
552
|
+
|
|
346
553
|
// src/commands/approve.ts
|
|
347
554
|
var USAGE = `Usage:
|
|
348
|
-
gtm approve icp --
|
|
349
|
-
gtm approve copy <campaign> --
|
|
555
|
+
gtm approve icp --pr <number> [--notes ".."] (approver is a GitHub @handle)
|
|
556
|
+
gtm approve copy <campaign> --pr <number> [--notes ".."]
|
|
557
|
+
gtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD] (approver is a name)
|
|
558
|
+
gtm approve copy <campaign> --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]`;
|
|
350
559
|
function load(file) {
|
|
351
560
|
if (!existsSync(file)) {
|
|
352
561
|
console.error(`${file} not found: run this from the root of a project repo`);
|
|
@@ -362,6 +571,7 @@ function load(file) {
|
|
|
362
571
|
function pin(file, doc, path, schema, hash, opts) {
|
|
363
572
|
const approval = { status: "approved", approved_by: opts.by, approved_at: opts.date, content_hash: hash };
|
|
364
573
|
if (opts.notes) approval.notes = opts.notes;
|
|
574
|
+
if (opts.via) approval.approved_via = opts.via;
|
|
365
575
|
const candidate = doc.toJS();
|
|
366
576
|
let target = candidate;
|
|
367
577
|
for (const key of path.slice(0, -1)) target = target[key] ??= {};
|
|
@@ -373,94 +583,469 @@ function pin(file, doc, path, schema, hash, opts) {
|
|
|
373
583
|
return false;
|
|
374
584
|
}
|
|
375
585
|
for (const [key, value] of Object.entries(approval)) doc.setIn([...path, key], value);
|
|
586
|
+
if (!opts.via) doc.deleteIn([...path, "approved_via"]);
|
|
376
587
|
writeFileSync(file, doc.toString({ flowCollectionPadding: false, lineWidth: 0 }));
|
|
377
588
|
return true;
|
|
378
589
|
}
|
|
379
|
-
function runApprove(root2, args2, opts) {
|
|
590
|
+
async function runApprove(root2, args2, opts) {
|
|
380
591
|
const [gate, campaign] = args2;
|
|
381
592
|
if (gate !== "icp" && gate !== "copy") {
|
|
382
593
|
console.error(USAGE);
|
|
383
594
|
if (gate) console.error(`The "${gate}" gate is recorded by hand in config.yaml \u2192 gates (AGENTS.md section 4).`);
|
|
384
595
|
return false;
|
|
385
596
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
console.error('--by is required: the human who approved, e.g. --by "Maria Chen (VP Marketing)"');
|
|
597
|
+
if (gate === "copy" && !campaign) {
|
|
598
|
+
console.error(USAGE);
|
|
389
599
|
return false;
|
|
390
600
|
}
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
601
|
+
const approver = (existsSync(join(root2, "project.yaml")) ? parse(readFileSync(join(root2, "project.yaml"), "utf8")) : null)?.owners?.approver;
|
|
602
|
+
const githubMode = isGitHubHandle(approver);
|
|
603
|
+
const contentPath = gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/copy.md`;
|
|
604
|
+
const contentFile = join(root2, contentPath);
|
|
605
|
+
if (!existsSync(contentFile)) {
|
|
606
|
+
console.error(`${contentPath} not found`);
|
|
394
607
|
return false;
|
|
395
608
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const hash2 = icpContentHash(doc2.toJS());
|
|
401
|
-
if (!pin(file2, doc2, ["approval"], IcpFile, hash2, { ...opts, by, date })) return false;
|
|
402
|
-
console.log(`ICP approved by ${by} on ${date} (content ${hash2}).`);
|
|
403
|
-
console.log("Commit context/icp.yaml together with the run log that quotes the approval.");
|
|
404
|
-
return true;
|
|
609
|
+
const local = readFileSync(contentFile, "utf8");
|
|
610
|
+
if (gate === "copy" && !local.split("\n").some((l) => l.trim() && !/^\s*(>|#)/.test(l))) {
|
|
611
|
+
console.error(`${contentPath} has no copy yet`);
|
|
612
|
+
return false;
|
|
405
613
|
}
|
|
406
|
-
|
|
407
|
-
|
|
614
|
+
const hashOf = (text) => gate === "icp" ? icpContentHash(parse(text)) : copyContentHash(text);
|
|
615
|
+
let by, date, via;
|
|
616
|
+
if (githubMode) {
|
|
617
|
+
if (!opts.pr) {
|
|
618
|
+
console.error(`The approver is ${approver} (GitHub mode): the approval is their APPROVED review on a pull request.`);
|
|
619
|
+
console.error(`Open a PR with ${contentPath}, have ${approver} approve it on GitHub, then run: npx gtm approve ${gate}${campaign ? ` ${campaign}` : ""} --pr <number>`);
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
|
|
623
|
+
if (typeof api === "string") {
|
|
624
|
+
console.error(`Cannot verify on GitHub: ${api}`);
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
const verified = await verifyPrApproval(api, opts.pr, approver, contentPath);
|
|
628
|
+
if (typeof verified === "string") {
|
|
629
|
+
console.error(verified);
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
if (hashOf(verified.approvedContent) !== hashOf(local)) {
|
|
633
|
+
console.error(`Your local ${contentPath} differs from the version ${approver} approved on the PR. Check out / pull the PR's latest commit, then run this again.`);
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
by = verified.reviewer;
|
|
637
|
+
date = verified.approvedAt;
|
|
638
|
+
via = verified.prUrl;
|
|
639
|
+
} else {
|
|
640
|
+
if (opts.pr) {
|
|
641
|
+
console.error(`--pr needs a GitHub approver: set project.yaml \u2192 owners.approver to their @handle, or record with --by.`);
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
by = opts.by?.trim() ?? "";
|
|
645
|
+
if (!by) {
|
|
646
|
+
console.error('--by is required: the human who approved, e.g. --by "Maria Chen (VP Marketing)"');
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
date = opts.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
650
|
+
if (!IsoDate.safeParse(date).success) {
|
|
651
|
+
console.error(`--date must be YYYY-MM-DD (got "${date}")`);
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const hash = hashOf(local);
|
|
656
|
+
const file = gate === "icp" ? contentFile : join(root2, "campaigns", campaign, "config.yaml");
|
|
657
|
+
const doc = load(file);
|
|
658
|
+
if (!doc) return false;
|
|
659
|
+
const path = gate === "icp" ? ["approval"] : ["gates", "copy"];
|
|
660
|
+
if (!pin(file, doc, path, gate === "icp" ? IcpFile : CampaignConfig, hash, { ...opts, by, date, via })) return false;
|
|
661
|
+
const what = gate === "icp" ? "ICP" : `Copy for ${campaign}`;
|
|
662
|
+
console.log(`${what} approved by ${by} on ${date}${via ? ` via ${via}` : ""} (content ${hash}).`);
|
|
663
|
+
console.log(`Commit ${gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/config.yaml and copy.md`} together with the run log that quotes the approval${via ? "" : ""}.`);
|
|
664
|
+
if (via) console.log(`Then close the tracker item: npx gtm track gate-done ${gate}${campaign ? ` ${campaign}` : ""}`);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/commands/icp.ts
|
|
669
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
670
|
+
import { join as join2 } from "path";
|
|
671
|
+
import { parse as parse2 } from "yaml";
|
|
672
|
+
var LABELS = {
|
|
673
|
+
industries: "industries",
|
|
674
|
+
employee_count: "employees",
|
|
675
|
+
revenue_usd: "revenue (USD)",
|
|
676
|
+
hq_countries: "HQ countries",
|
|
677
|
+
technologies_any: "tech (any of)",
|
|
678
|
+
technologies_none: "tech (none of)",
|
|
679
|
+
keywords_any: "keywords (any)",
|
|
680
|
+
keywords_none: "keywords (none)"
|
|
681
|
+
};
|
|
682
|
+
function show(value) {
|
|
683
|
+
if (Array.isArray(value)) return value.length ? value.join(", ") : null;
|
|
684
|
+
if (value && typeof value === "object") {
|
|
685
|
+
const r = value;
|
|
686
|
+
return r.min !== void 0 && r.max !== void 0 ? `${r.min}\u2013${r.max}` : r.min !== void 0 ? `\u2265 ${r.min}` : r.max !== void 0 ? `\u2264 ${r.max}` : null;
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
function runIcp(root2) {
|
|
691
|
+
const file = join2(root2, "context", "icp.yaml");
|
|
692
|
+
if (!existsSync2(file)) {
|
|
693
|
+
console.error("context/icp.yaml not found: run this from the root of a project repo");
|
|
408
694
|
return false;
|
|
409
695
|
}
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
console.error(`
|
|
696
|
+
const parsed = IcpFile.safeParse(parse2(readFileSync2(file, "utf8")));
|
|
697
|
+
if (!parsed.success) {
|
|
698
|
+
console.error("context/icp.yaml is not valid (run `npx gtm validate` for details):");
|
|
699
|
+
for (const i of parsed.error.issues) console.error(` \u2717 ${i.path.join(".") || "(root)"}: ${i.message}`);
|
|
414
700
|
return false;
|
|
415
701
|
}
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
702
|
+
const icp = parsed.data;
|
|
703
|
+
const resolved = resolveIcp(icp);
|
|
704
|
+
console.log(`ICP for ${icp.project_id} \xB7 approval: ${icp.approval.status}${icp.approval.approved_by ? ` by ${icp.approval.approved_by}` : ""}`);
|
|
705
|
+
const core = icp.core?.firmographics ?? {};
|
|
706
|
+
const coreLines = Object.keys(LABELS).map((k) => [LABELS[k], show(core[k])]).filter((line) => line[1] !== null);
|
|
707
|
+
console.log(`
|
|
708
|
+
Core (every segment): ${coreLines.length ? "" : "none (each segment defines its own filters)"}`);
|
|
709
|
+
for (const [label, v] of coreLines) console.log(` ${label.padEnd(16)}${v}`);
|
|
710
|
+
for (const s of resolved.segments) {
|
|
711
|
+
console.log(`
|
|
712
|
+
Segment ${s.id}: ${s.description}`);
|
|
713
|
+
for (const k of Object.keys(LABELS)) {
|
|
714
|
+
const v = show(s.firmographics[k]);
|
|
715
|
+
if (v) console.log(` ${LABELS[k].padEnd(16)}${v}${s.overrides.includes(k) ? core[k] !== void 0 ? " \u2190 segment (replaces core)" : " \u2190 segment" : ""}`);
|
|
716
|
+
}
|
|
717
|
+
console.log(` ${"personas".padEnd(16)}${s.personas.join(", ")}${s.personas.length < icp.personas.length ? " \u2190 restricted" : ""}`);
|
|
718
|
+
}
|
|
719
|
+
const x = resolved.exclusions;
|
|
720
|
+
const ex = [x.domains.length && `${x.domains.length} domain(s)`, x.industries.length && `industries: ${x.industries.join(", ")}`, x.hq_countries.length && `countries: ${x.hq_countries.join(", ")}`].filter(Boolean);
|
|
721
|
+
console.log(`
|
|
722
|
+
Exclusions (all segments): ${ex.length ? ex.join(" \xB7 ") : "none"}`);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/commands/github.ts
|
|
727
|
+
var LABELS2 = [
|
|
728
|
+
{ name: "gtm", color: "5319e7", description: "Managed by gtm track" },
|
|
729
|
+
{ name: "campaign", color: "1d76db", description: "gtm campaign tracker" },
|
|
730
|
+
{ name: "gate", color: "d93f0b", description: "gtm approval gate (hard stop)" },
|
|
731
|
+
...["icp", "copy", "launch"].map((g) => ({ name: `gate:${g}`, color: "d93f0b", description: `gtm ${g} gate` })),
|
|
732
|
+
...Stage.options.map((s) => ({ name: `stage:${s}`, color: "c5def5", description: `gtm stage ${s}` }))
|
|
733
|
+
];
|
|
734
|
+
var RULESET_NAME = "gtm: approvals";
|
|
735
|
+
function rulesetBody() {
|
|
736
|
+
return {
|
|
737
|
+
name: RULESET_NAME,
|
|
738
|
+
target: "branch",
|
|
739
|
+
enforcement: "active",
|
|
740
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
741
|
+
rules: [
|
|
742
|
+
{ type: "deletion" },
|
|
743
|
+
{ type: "non_fast_forward" },
|
|
744
|
+
{
|
|
745
|
+
type: "pull_request",
|
|
746
|
+
parameters: {
|
|
747
|
+
required_approving_review_count: 0,
|
|
748
|
+
require_code_owner_review: true,
|
|
749
|
+
// Off on purpose: `gtm approve --pr` commits the approval record to the PR after the review.
|
|
750
|
+
// Content after approval is guarded by the pinned hash, re-checked by `gtm validate
|
|
751
|
+
// --verify-github` against the exact reviewed commit (a required CI check).
|
|
752
|
+
dismiss_stale_reviews_on_push: false,
|
|
753
|
+
require_last_push_approval: false,
|
|
754
|
+
required_review_thread_resolution: false
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
{ type: "required_status_checks", parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: "check" }] } }
|
|
758
|
+
]
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
async function runGithub(root2, args2, opts) {
|
|
762
|
+
if (args2[0] !== "setup") {
|
|
763
|
+
console.error("Usage: gtm github setup [--apply]");
|
|
419
764
|
return false;
|
|
420
765
|
}
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
console.log(`
|
|
766
|
+
const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
|
|
767
|
+
if (typeof api === "string") {
|
|
768
|
+
console.error(api);
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
const repo = `${api.repo.owner}/${api.repo.repo}`;
|
|
772
|
+
console.log(`GitHub setup for ${repo}${opts.apply ? "" : " (plan only: re-run with --apply)"}:`);
|
|
773
|
+
console.log(` \u2022 labels: ${LABELS2.map((l) => l.name).join(", ")}`);
|
|
774
|
+
console.log(` \u2022 ruleset "${RULESET_NAME}" on the default branch: PR required, review from CODEOWNERS required, CI job "check" must pass, no force-push or deletion`);
|
|
775
|
+
console.log(" \u2022 CODEOWNERS is maintained by `npx gtm sync` from project.yaml \u2192 owners.approver");
|
|
776
|
+
if (!opts.apply) return true;
|
|
777
|
+
const existing = new Set((await api.request("GET", "repo:/labels?per_page=100")).map((l) => l.name));
|
|
778
|
+
for (const label of LABELS2) {
|
|
779
|
+
if (existing.has(label.name)) continue;
|
|
780
|
+
await api.request("POST", "repo:/labels", label);
|
|
781
|
+
console.log(` + label ${label.name}`);
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const rulesets = await api.request("GET", "repo:/rulesets");
|
|
785
|
+
const found = rulesets.find((r) => r.name === RULESET_NAME);
|
|
786
|
+
if (found) await api.request("PUT", `repo:/rulesets/${found.id}`, rulesetBody());
|
|
787
|
+
else await api.request("POST", "repo:/rulesets", rulesetBody());
|
|
788
|
+
console.log(` ${found ? "~ updated" : "+ created"} ruleset "${RULESET_NAME}"`);
|
|
789
|
+
} catch (e) {
|
|
790
|
+
if (e instanceof GitHubError && (e.status === 403 || e.status === 404)) {
|
|
791
|
+
console.error(` \u2717 could not create the ruleset (${e.status}). Rulesets on private repos need a paid GitHub plan (Team/Pro), and the token needs admin rights.`);
|
|
792
|
+
console.error(" Without it GitHub still requests the approver's review via CODEOWNERS, and `gtm validate --verify-github` in CI still verifies every approval.");
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
throw e;
|
|
796
|
+
}
|
|
797
|
+
console.log("Done. In CI, `npx gtm validate --verify-github` checks each recorded approval against GitHub.");
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/commands/track.ts
|
|
802
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
803
|
+
import { join as join3 } from "path";
|
|
804
|
+
import { parse as parse3 } from "yaml";
|
|
805
|
+
|
|
806
|
+
// src/tracker/github.ts
|
|
807
|
+
var marker = (key) => `<!-- gtm-key: ${key} -->`;
|
|
808
|
+
var BASE_LABEL = "gtm";
|
|
809
|
+
var GitHubTracker = class {
|
|
810
|
+
constructor(api) {
|
|
811
|
+
this.api = api;
|
|
812
|
+
}
|
|
813
|
+
api;
|
|
814
|
+
kind = "github";
|
|
815
|
+
async find(key) {
|
|
816
|
+
for (let page = 1; page <= 10; page++) {
|
|
817
|
+
const issues = await this.api.request("GET", `repo:/issues?labels=${BASE_LABEL}&state=all&per_page=100&page=${page}`);
|
|
818
|
+
const hit = issues.find((i) => !i.pull_request && i.body?.includes(marker(key)));
|
|
819
|
+
if (hit) return hit;
|
|
820
|
+
if (issues.length < 100) return null;
|
|
821
|
+
}
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
async upsert(item) {
|
|
825
|
+
const body = `${item.body}
|
|
826
|
+
|
|
827
|
+
${marker(item.key)}`;
|
|
828
|
+
const wanted = [.../* @__PURE__ */ new Set([BASE_LABEL, ...item.labels])];
|
|
829
|
+
const existing = await this.find(item.key);
|
|
830
|
+
if (!existing) {
|
|
831
|
+
const created = await this.api.request("POST", "repo:/issues", { title: item.title, body, labels: wanted, assignees: item.assignees ?? [] });
|
|
832
|
+
return { key: item.key, url: created.html_url, created: true };
|
|
833
|
+
}
|
|
834
|
+
const exclusive = item.exclusiveLabelPrefixes ?? [];
|
|
835
|
+
const kept = existing.labels.map((l) => l.name).filter((n) => !exclusive.some((p) => n.startsWith(p)) || wanted.includes(n));
|
|
836
|
+
await this.api.request("PATCH", `repo:/issues/${existing.number}`, {
|
|
837
|
+
title: item.title,
|
|
838
|
+
body,
|
|
839
|
+
state: "open",
|
|
840
|
+
labels: [.../* @__PURE__ */ new Set([...kept, ...wanted])],
|
|
841
|
+
...item.assignees ? { assignees: item.assignees } : {}
|
|
842
|
+
});
|
|
843
|
+
return { key: item.key, url: existing.html_url, created: false };
|
|
844
|
+
}
|
|
845
|
+
async comment(key, text) {
|
|
846
|
+
const issue = await this.find(key);
|
|
847
|
+
if (!issue) throw new Error(`no tracked issue for "${key}"`);
|
|
848
|
+
await this.api.request("POST", `repo:/issues/${issue.number}/comments`, { body: text });
|
|
849
|
+
}
|
|
850
|
+
async close(key, text) {
|
|
851
|
+
const issue = await this.find(key);
|
|
852
|
+
if (!issue) throw new Error(`no tracked issue for "${key}"`);
|
|
853
|
+
if (text) await this.api.request("POST", `repo:/issues/${issue.number}/comments`, { body: text });
|
|
854
|
+
await this.api.request("PATCH", `repo:/issues/${issue.number}`, { state: "closed", state_reason: "completed" });
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// src/tracker/none.ts
|
|
859
|
+
var noneTracker = {
|
|
860
|
+
kind: "none",
|
|
861
|
+
async upsert(item) {
|
|
862
|
+
return { key: item.key, url: "", created: false };
|
|
863
|
+
},
|
|
864
|
+
async comment() {
|
|
865
|
+
},
|
|
866
|
+
async close() {
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// src/tracker/index.ts
|
|
871
|
+
function createTracker(kind, root2, fetchImpl = fetch) {
|
|
872
|
+
if (kind === "none") return noneTracker;
|
|
873
|
+
if (kind === "github") {
|
|
874
|
+
const api = GitHubApi.fromRepo(root2, fetchImpl);
|
|
875
|
+
return typeof api === "string" ? `tracker github: ${api}` : new GitHubTracker(api);
|
|
876
|
+
}
|
|
877
|
+
return `unknown tracker kind "${kind}" (supported: github, none)`;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/commands/track.ts
|
|
881
|
+
var GATES = ["icp", "copy", "launch"];
|
|
882
|
+
var USAGE2 = `Usage:
|
|
883
|
+
gtm track stage <campaign>
|
|
884
|
+
gtm track gate-open <icp|copy|launch> [campaign] [--pr <number>]
|
|
885
|
+
gtm track gate-done <icp|copy|launch> [campaign]`;
|
|
886
|
+
function readYaml(file) {
|
|
887
|
+
return existsSync3(file) ? parse3(readFileSync3(file, "utf8")) : null;
|
|
888
|
+
}
|
|
889
|
+
var handles = (...values2) => values2.filter(isGitHubHandle).map((h) => h.slice(1));
|
|
890
|
+
async function upsert(ctx, item) {
|
|
891
|
+
try {
|
|
892
|
+
const res = await ctx.tracker.upsert(item);
|
|
893
|
+
return res.url;
|
|
894
|
+
} catch (e) {
|
|
895
|
+
if (e instanceof GitHubError && e.status === 422 && item.assignees?.length) {
|
|
896
|
+
console.log(` ! could not assign ${item.assignees.map((a) => "@" + a).join(", ")} (not a collaborator?): created unassigned`);
|
|
897
|
+
return (await ctx.tracker.upsert({ ...item, assignees: [] })).url;
|
|
898
|
+
}
|
|
899
|
+
throw e;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
function gateKey(gate, campaign) {
|
|
903
|
+
return gate === "icp" ? "gate:icp" : `gate:${gate}:${campaign}`;
|
|
904
|
+
}
|
|
905
|
+
function gateFile(gate, campaign) {
|
|
906
|
+
return gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/config.yaml`;
|
|
907
|
+
}
|
|
908
|
+
function gateApproval(root2, gate, campaign) {
|
|
909
|
+
const doc = readYaml(join3(root2, gateFile(gate, campaign)));
|
|
910
|
+
return gate === "icp" ? doc?.approval : doc?.gates?.[gate];
|
|
911
|
+
}
|
|
912
|
+
async function trackStage(ctx, campaign) {
|
|
913
|
+
const cfg = campaign ? readYaml(join3(ctx.root, "campaigns", campaign, "config.yaml")) : null;
|
|
914
|
+
if (!campaign || !cfg) {
|
|
915
|
+
console.error(campaign ? `campaigns/${campaign}/config.yaml not found` : USAGE2);
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
const current = Stage.safeParse(cfg.stage).success ? cfg.stage : null;
|
|
919
|
+
if (!current) {
|
|
920
|
+
console.error(`campaigns/${campaign}/config.yaml has no valid stage`);
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
const idx = Stage.options.indexOf(current);
|
|
924
|
+
const checklist = Stage.options.map((s, i) => `- [${i < idx ? "x" : " "}] ${s}${s === current ? " \u2190 current" : ""}`).join("\n");
|
|
925
|
+
const url = await upsert(ctx, {
|
|
926
|
+
key: `campaign:${campaign}`,
|
|
927
|
+
title: `Campaign: ${campaign}`,
|
|
928
|
+
body: `**Stage: \`${current}\`** (source of truth: \`campaigns/${campaign}/config.yaml\`)
|
|
929
|
+
|
|
930
|
+
${checklist}
|
|
931
|
+
|
|
932
|
+
Brief: \`campaigns/${campaign}/brief.md\`. Counts and decisions only in this issue: never lead data.`,
|
|
933
|
+
labels: ["campaign", `stage:${current}`],
|
|
934
|
+
exclusiveLabelPrefixes: ["stage:"],
|
|
935
|
+
assignees: handles(cfg.owner, ctx.project?.owners?.gtm_engineer)
|
|
936
|
+
});
|
|
937
|
+
if (url) await ctx.tracker.comment(`campaign:${campaign}`, `Stage \u2192 \`${current}\``);
|
|
938
|
+
console.log(url ? `tracked campaign ${campaign} at stage ${current}: ${url}` : `tracker none: skipped (campaign ${campaign} at ${current})`);
|
|
428
939
|
return true;
|
|
429
940
|
}
|
|
941
|
+
var GATE_TEXT = {
|
|
942
|
+
icp: { title: "ICP sign-off", review: "segments + filters, personas, exclusions, signals with weights, expected TAM range (`context/icp.md`, `context/icp.yaml`, `context/signals.yaml`)" },
|
|
943
|
+
copy: { title: "Copy sign-off", review: "every step of every variant in `copy.md`, rendered with one realistic fictional example" },
|
|
944
|
+
launch: { title: "Launch go/no-go", review: "`qa.md` result, leads by tier, daily volume, spend so far, paused sender campaign" }
|
|
945
|
+
};
|
|
946
|
+
async function gateOpen(ctx, gate, campaign, pr) {
|
|
947
|
+
if (gate !== "icp" && !campaign) {
|
|
948
|
+
console.error(USAGE2);
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
const owners = ctx.project?.owners ?? {};
|
|
952
|
+
const approver = gate === "launch" ? owners.gtm_engineer : owners.approver;
|
|
953
|
+
const how = isGitHubHandle(approver) ? `**${approver}**: review and approve the pull request${pr ? ` #${pr}` : ""} on GitHub. Then the agent records it with \`npx gtm approve ${gate === "icp" ? "icp" : `${gate} ${campaign}`} --pr <number>\`.` : `Approver **${approver ?? "(not set)"}**: reply with an explicit approval; it is recorded with \`npx gtm approve ... --by "<name (role)>"\`.`;
|
|
954
|
+
const key = gateKey(gate, campaign);
|
|
955
|
+
const url = await upsert(ctx, {
|
|
956
|
+
key,
|
|
957
|
+
title: `${GATE_TEXT[gate].title}${campaign ? `: ${campaign}` : ""}`,
|
|
958
|
+
body: `**Hard stop.** Nothing moves past this gate until it is approved.
|
|
959
|
+
|
|
960
|
+
Review: ${GATE_TEXT[gate].review}.
|
|
961
|
+
|
|
962
|
+
${how}
|
|
963
|
+
|
|
964
|
+
Source of truth: \`${gateFile(gate, campaign)}\`.`,
|
|
965
|
+
labels: ["gate", `gate:${gate}`],
|
|
966
|
+
assignees: handles(approver)
|
|
967
|
+
});
|
|
968
|
+
console.log(url ? `opened ${GATE_TEXT[gate].title}: ${url}` : `tracker none: skipped (${key})`);
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
async function gateDone(ctx, gate, campaign) {
|
|
972
|
+
if (gate !== "icp" && !campaign) {
|
|
973
|
+
console.error(USAGE2);
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
const approval = gateApproval(ctx.root, gate, campaign);
|
|
977
|
+
if (approval?.status !== "approved") {
|
|
978
|
+
console.error(`${gateFile(gate, campaign)} says this gate is "${approval?.status ?? "missing"}": record the approval first; the tracker only mirrors it.`);
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
const via = approval.approved_via ? ` via ${approval.approved_via}` : "";
|
|
982
|
+
if (ctx.tracker.kind === "none") {
|
|
983
|
+
console.log(`tracker none: skipped (${gateKey(gate, campaign)})`);
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
await ctx.tracker.close(gateKey(gate, campaign), `\u2705 Approved by ${approval.approved_by} on ${approval.approved_at}${via}.`);
|
|
987
|
+
console.log(`closed ${GATE_TEXT[gate].title}${campaign ? `: ${campaign}` : ""}`);
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
async function runTrack(root2, args2, opts = {}) {
|
|
991
|
+
const [action, a1, a2] = args2;
|
|
992
|
+
const project = readYaml(join3(root2, "project.yaml"));
|
|
993
|
+
if (!project) {
|
|
994
|
+
console.error("project.yaml not found: run this from the root of a project repo");
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
997
|
+
const tracker = createTracker(project.tracker?.kind ?? "none", root2, opts.fetchImpl);
|
|
998
|
+
if (typeof tracker === "string") {
|
|
999
|
+
console.error(tracker);
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
const ctx = { root: root2, project, tracker };
|
|
1003
|
+
try {
|
|
1004
|
+
if (action === "stage") return await trackStage(ctx, a1);
|
|
1005
|
+
if ((action === "gate-open" || action === "gate-done") && GATES.includes(a1)) {
|
|
1006
|
+
return action === "gate-open" ? await gateOpen(ctx, a1, a2, opts.pr) : await gateDone(ctx, a1, a2);
|
|
1007
|
+
}
|
|
1008
|
+
console.error(USAGE2);
|
|
1009
|
+
return false;
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
console.error(`tracker error: ${e.message}`);
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
430
1015
|
|
|
431
1016
|
// src/commands/init.ts
|
|
432
|
-
import { spawnSync } from "child_process";
|
|
433
|
-
import { appendFileSync, existsSync as
|
|
434
|
-
import { basename, join as
|
|
1017
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1018
|
+
import { appendFileSync, existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
|
|
1019
|
+
import { basename, join as join8, relative as relative2, resolve } from "path";
|
|
435
1020
|
|
|
436
1021
|
// src/paths.ts
|
|
437
|
-
import { existsSync as
|
|
438
|
-
import { dirname, join as
|
|
1022
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
1023
|
+
import { dirname, join as join4 } from "path";
|
|
439
1024
|
import { fileURLToPath } from "url";
|
|
440
|
-
var pkgRoot =
|
|
1025
|
+
var pkgRoot = join4(dirname(fileURLToPath(import.meta.url)), "..");
|
|
441
1026
|
var PACKAGE_NAME = "@veleads/gtm";
|
|
442
|
-
var GTM_VERSION = JSON.parse(
|
|
1027
|
+
var GTM_VERSION = JSON.parse(readFileSync4(join4(pkgRoot, "package.json"), "utf8")).version;
|
|
443
1028
|
function firstExisting(candidates) {
|
|
444
|
-
return candidates.find((p) =>
|
|
1029
|
+
return candidates.find((p) => existsSync4(p)) ?? null;
|
|
445
1030
|
}
|
|
446
|
-
var kit = firstExisting([
|
|
1031
|
+
var kit = firstExisting([join4(pkgRoot, "kit"), join4(pkgRoot, "..", "..", "kit")]);
|
|
447
1032
|
if (!kit) throw new Error("gtm: kit/ not found next to the package; reinstall @veleads/gtm");
|
|
448
1033
|
var KIT_DIR = kit;
|
|
449
|
-
var CHANGELOG_FILE = firstExisting([
|
|
1034
|
+
var CHANGELOG_FILE = firstExisting([join4(pkgRoot, "CHANGELOG.md"), join4(pkgRoot, "..", "..", "CHANGELOG.md")]);
|
|
450
1035
|
|
|
451
1036
|
// src/template.ts
|
|
452
|
-
import { mkdirSync, readFileSync as
|
|
453
|
-
import { dirname as dirname2, join as
|
|
1037
|
+
import { mkdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
1038
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
454
1039
|
|
|
455
1040
|
// src/fsutil.ts
|
|
456
1041
|
import { readdirSync, statSync } from "fs";
|
|
457
|
-
import { join as
|
|
1042
|
+
import { join as join5, relative } from "path";
|
|
458
1043
|
function walk(dir, skip = []) {
|
|
459
1044
|
const out = [];
|
|
460
1045
|
const visit = (d) => {
|
|
461
1046
|
for (const name of readdirSync(d)) {
|
|
462
1047
|
if (skip.includes(name)) continue;
|
|
463
|
-
const p =
|
|
1048
|
+
const p = join5(d, name);
|
|
464
1049
|
if (statSync(p).isDirectory()) visit(p);
|
|
465
1050
|
else out.push(relative(dir, p));
|
|
466
1051
|
}
|
|
@@ -470,7 +1055,7 @@ function walk(dir, skip = []) {
|
|
|
470
1055
|
}
|
|
471
1056
|
function subdirs(dir) {
|
|
472
1057
|
try {
|
|
473
|
-
return readdirSync(dir).filter((n) => !n.startsWith(".") && statSync(
|
|
1058
|
+
return readdirSync(dir).filter((n) => !n.startsWith(".") && statSync(join5(dir, n)).isDirectory()).sort();
|
|
474
1059
|
} catch {
|
|
475
1060
|
return [];
|
|
476
1061
|
}
|
|
@@ -507,7 +1092,7 @@ function destName(rel) {
|
|
|
507
1092
|
}
|
|
508
1093
|
function planTemplate(src, vars, rename = destName) {
|
|
509
1094
|
return walk(src).map((rel) => {
|
|
510
|
-
const raw =
|
|
1095
|
+
const raw = readFileSync5(join6(src, rel));
|
|
511
1096
|
if (!/\.(md|ya?ml|json|txt|example)$|gitignore$/.test(rel)) return { rel: rename(rel), content: raw };
|
|
512
1097
|
let text = raw.toString("utf8");
|
|
513
1098
|
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`__${k}__`, v);
|
|
@@ -515,7 +1100,7 @@ function planTemplate(src, vars, rename = destName) {
|
|
|
515
1100
|
});
|
|
516
1101
|
}
|
|
517
1102
|
function writePlanned(root2, file) {
|
|
518
|
-
const out =
|
|
1103
|
+
const out = join6(root2, file.rel);
|
|
519
1104
|
mkdirSync(dirname2(out), { recursive: true });
|
|
520
1105
|
writeFileSync2(out, file.content);
|
|
521
1106
|
}
|
|
@@ -524,25 +1109,41 @@ function copyTemplate(src, dest, vars, opts = {}) {
|
|
|
524
1109
|
}
|
|
525
1110
|
|
|
526
1111
|
// src/commands/sync.ts
|
|
527
|
-
import { existsSync as
|
|
528
|
-
import {
|
|
529
|
-
|
|
1112
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1113
|
+
import { parse as parse4 } from "yaml";
|
|
1114
|
+
import { dirname as dirname3, join as join7 } from "path";
|
|
1115
|
+
var SHIPPED_DIRS = ["skills", "templates", "providers", "schemas", "guides"];
|
|
1116
|
+
var CODEOWNERS = join7(".github", "CODEOWNERS");
|
|
1117
|
+
var BLOCK_START = "# BEGIN gtm: generated by `gtm sync` from project.yaml \u2192 owners.approver. Edit owners there, not here.";
|
|
1118
|
+
var BLOCK_END = "# END gtm";
|
|
1119
|
+
function expectedCodeowners(root2) {
|
|
1120
|
+
const projectFile = join7(root2, "project.yaml");
|
|
1121
|
+
const approver = existsSync5(projectFile) ? parse4(readFileSync6(projectFile, "utf8"))?.owners?.approver : void 0;
|
|
1122
|
+
const path = join7(root2, CODEOWNERS);
|
|
1123
|
+
const current = existsSync5(path) ? readFileSync6(path, "utf8") : "";
|
|
1124
|
+
const outside = current.replace(new RegExp(`\\n?${BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${BLOCK_END}\\n?`), "\n").trim();
|
|
1125
|
+
const block = isGitHubHandle(approver) ? [BLOCK_START, `/context/icp.yaml ${approver}`, `/context/signals.yaml ${approver}`, `/campaigns/*/copy.md ${approver}`, BLOCK_END].join("\n") : "";
|
|
1126
|
+
const next = [outside, block].filter(Boolean).join("\n\n");
|
|
1127
|
+
if (!next) return current ? "" : null;
|
|
1128
|
+
return `${next}
|
|
1129
|
+
`;
|
|
1130
|
+
}
|
|
530
1131
|
function agentsMd() {
|
|
531
1132
|
const header = `<!-- GENERATED by \`gtm sync\` from ${PACKAGE_NAME} v${GTM_VERSION}. Do not edit: it is overwritten on upgrade.
|
|
532
1133
|
Project-specific rules go in PROJECT.md. Process changes go to the Veleads/gtm-core repo. -->
|
|
533
1134
|
|
|
534
1135
|
`;
|
|
535
|
-
return header +
|
|
1136
|
+
return header + readFileSync6(join7(KIT_DIR, "AGENTS.md"), "utf8").replaceAll("{{GTM_VERSION}}", GTM_VERSION);
|
|
536
1137
|
}
|
|
537
1138
|
function expectedFiles() {
|
|
538
1139
|
const files = /* @__PURE__ */ new Map();
|
|
539
1140
|
for (const dir of SHIPPED_DIRS) {
|
|
540
|
-
for (const rel of walk(
|
|
1141
|
+
for (const rel of walk(join7(KIT_DIR, dir))) files.set(join7(".gtm", dir, rel), readFileSync6(join7(KIT_DIR, dir, rel), "utf8"));
|
|
541
1142
|
}
|
|
542
|
-
files.set(
|
|
1143
|
+
files.set(join7(".gtm", "VERSION"), `${GTM_VERSION}
|
|
543
1144
|
`);
|
|
544
1145
|
files.set(
|
|
545
|
-
|
|
1146
|
+
join7(".gtm", "README.md"),
|
|
546
1147
|
`# .gtm/
|
|
547
1148
|
|
|
548
1149
|
Copied from ${PACKAGE_NAME} v${GTM_VERSION} by \`gtm sync\`. Read-only: CI fails if it differs from the
|
|
@@ -556,13 +1157,16 @@ function checkSync(root2) {
|
|
|
556
1157
|
const expected = expectedFiles();
|
|
557
1158
|
const diffs = [];
|
|
558
1159
|
for (const [rel, content] of expected) {
|
|
559
|
-
const p =
|
|
560
|
-
if (!
|
|
561
|
-
else if (
|
|
1160
|
+
const p = join7(root2, rel);
|
|
1161
|
+
if (!existsSync5(p)) diffs.push(`missing ${rel}`);
|
|
1162
|
+
else if (readFileSync6(p, "utf8") !== content) diffs.push(`changed ${rel}`);
|
|
562
1163
|
}
|
|
563
|
-
if (
|
|
564
|
-
for (const rel of walk(
|
|
1164
|
+
if (existsSync5(join7(root2, ".gtm"))) {
|
|
1165
|
+
for (const rel of walk(join7(root2, ".gtm"))) if (!expected.has(join7(".gtm", rel))) diffs.push(`extra ${join7(".gtm", rel)}`);
|
|
565
1166
|
}
|
|
1167
|
+
const owners = expectedCodeowners(root2);
|
|
1168
|
+
const ownersPath = join7(root2, CODEOWNERS);
|
|
1169
|
+
if (owners !== null && (existsSync5(ownersPath) ? readFileSync6(ownersPath, "utf8") : "") !== owners) diffs.push(`changed ${CODEOWNERS} (gtm block)`);
|
|
566
1170
|
return diffs;
|
|
567
1171
|
}
|
|
568
1172
|
function runSync(root2, opts = {}) {
|
|
@@ -576,18 +1180,23 @@ function runSync(root2, opts = {}) {
|
|
|
576
1180
|
for (const d of diffs) console.log(` ${d}`);
|
|
577
1181
|
return false;
|
|
578
1182
|
}
|
|
579
|
-
rmSync(
|
|
1183
|
+
rmSync(join7(root2, ".gtm"), { recursive: true, force: true });
|
|
580
1184
|
for (const [rel, content] of expectedFiles()) {
|
|
581
|
-
mkdirSync2(dirname3(
|
|
582
|
-
writeFileSync3(
|
|
1185
|
+
mkdirSync2(dirname3(join7(root2, rel)), { recursive: true });
|
|
1186
|
+
writeFileSync3(join7(root2, rel), content);
|
|
1187
|
+
}
|
|
1188
|
+
if (!existsSync5(join7(root2, "CLAUDE.md"))) writeFileSync3(join7(root2, "CLAUDE.md"), "@AGENTS.md\n");
|
|
1189
|
+
const owners = expectedCodeowners(root2);
|
|
1190
|
+
if (owners !== null) {
|
|
1191
|
+
mkdirSync2(join7(root2, ".github"), { recursive: true });
|
|
1192
|
+
writeFileSync3(join7(root2, CODEOWNERS), owners);
|
|
583
1193
|
}
|
|
584
|
-
if (!existsSync3(join5(root2, "CLAUDE.md"))) writeFileSync3(join5(root2, "CLAUDE.md"), "@AGENTS.md\n");
|
|
585
1194
|
console.log(`synced .gtm/ and AGENTS.md to ${PACKAGE_NAME} v${GTM_VERSION}`);
|
|
586
1195
|
return true;
|
|
587
1196
|
}
|
|
588
1197
|
|
|
589
1198
|
// src/commands/init.ts
|
|
590
|
-
var
|
|
1199
|
+
var USAGE3 = `Usage:
|
|
591
1200
|
gtm init <project-slug> --name "<Project Name>" [--dir <path>] create ./gtm-project-<slug> (or <path>)
|
|
592
1201
|
gtm init . --name "<Project Name>" [--slug <project-slug>] set up the current folder
|
|
593
1202
|
Slugs: lowercase letters, digits and hyphens.`;
|
|
@@ -604,26 +1213,26 @@ function runInit(target, opts) {
|
|
|
604
1213
|
const root2 = resolve(opts.cwd, here ? opts.dir ?? "." : opts.dir ?? `gtm-project-${target ?? ""}`);
|
|
605
1214
|
const slug = target && target !== "." ? target : opts.slug ?? slugFromFolder(root2);
|
|
606
1215
|
if (!opts.name || !slug || !Slug.safeParse(slug).success) {
|
|
607
|
-
console.error(
|
|
1216
|
+
console.error(USAGE3);
|
|
608
1217
|
if (!slug && here) console.error(`Couldn't derive a slug from the folder name "${basename(root2)}": pass one with --slug`);
|
|
609
1218
|
else if (slug && !Slug.safeParse(slug).success) console.error(`"${slug}" is not a valid slug: pass one with --slug`);
|
|
610
1219
|
return false;
|
|
611
1220
|
}
|
|
612
|
-
if (
|
|
1221
|
+
if (existsSync6(join8(root2, "project.yaml")) || existsSync6(join8(root2, "client.yaml"))) {
|
|
613
1222
|
console.error(`${root2} is already a gtm project. Use \`npx gtm sync\` or \`npx gtm upgrade\` instead.`);
|
|
614
1223
|
return false;
|
|
615
1224
|
}
|
|
616
1225
|
const vars = { PROJECT_ID: slug, PROJECT_NAME: opts.name, TODAY: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), GTM_VERSION };
|
|
617
|
-
const tpl =
|
|
1226
|
+
const tpl = join8(KIT_DIR, "templates");
|
|
618
1227
|
const planned = [
|
|
619
|
-
...planTemplate(
|
|
620
|
-
...planTemplate(
|
|
1228
|
+
...planTemplate(join8(tpl, "repo"), vars),
|
|
1229
|
+
...planTemplate(join8(tpl, "project"), vars, (rel) => rel === "project.yaml" ? rel : join8("context", rel)),
|
|
621
1230
|
{ rel: "campaigns/.gitkeep", content: "" },
|
|
622
1231
|
{ rel: "tam/.gitkeep", content: "" }
|
|
623
1232
|
];
|
|
624
1233
|
const coreOwned = [...expectedFiles().keys()];
|
|
625
|
-
const conflicts = [...planned.map((f) => f.rel), ...coreOwned].filter((rel) =>
|
|
626
|
-
if (
|
|
1234
|
+
const conflicts = [...planned.map((f) => f.rel), ...coreOwned].filter((rel) => existsSync6(join8(root2, rel)) && !MERGEABLE.has(rel));
|
|
1235
|
+
if (existsSync6(join8(root2, ".gtm"))) conflicts.unshift(".gtm/");
|
|
627
1236
|
if (conflicts.length) {
|
|
628
1237
|
console.error(`Cannot set up ${root2}: these files already exist and belong to gtm. Nothing was written.`);
|
|
629
1238
|
for (const c of [...new Set(conflicts)].slice(0, 15)) console.error(` \u2717 ${c}`);
|
|
@@ -632,13 +1241,13 @@ function runInit(target, opts) {
|
|
|
632
1241
|
}
|
|
633
1242
|
const notes = [];
|
|
634
1243
|
for (const file of planned) {
|
|
635
|
-
const path =
|
|
636
|
-
if (!
|
|
1244
|
+
const path = join8(root2, file.rel);
|
|
1245
|
+
if (!existsSync6(path)) {
|
|
637
1246
|
writePlanned(root2, file);
|
|
638
1247
|
continue;
|
|
639
1248
|
}
|
|
640
1249
|
if (file.rel === ".gitignore") {
|
|
641
|
-
const add = missingIgnoreLines(
|
|
1250
|
+
const add = missingIgnoreLines(readFileSync7(path, "utf8"), file.content.toString());
|
|
642
1251
|
if (add.length) {
|
|
643
1252
|
appendFileSync(path, `
|
|
644
1253
|
# gtm-brain
|
|
@@ -650,15 +1259,15 @@ ${add.join("\n")}
|
|
|
650
1259
|
notes.push("README.md: kept yours (see .gtm/templates/repo/README.md for the gtm version)");
|
|
651
1260
|
}
|
|
652
1261
|
}
|
|
653
|
-
const claude =
|
|
654
|
-
const hadClaude =
|
|
1262
|
+
const claude = join8(root2, "CLAUDE.md");
|
|
1263
|
+
const hadClaude = existsSync6(claude);
|
|
655
1264
|
runSync(root2);
|
|
656
|
-
if (hadClaude && !
|
|
1265
|
+
if (hadClaude && !readFileSync7(claude, "utf8").includes("@AGENTS.md")) {
|
|
657
1266
|
appendFileSync(claude, "\n@AGENTS.md\n");
|
|
658
1267
|
notes.push("CLAUDE.md: added @AGENTS.md so Claude Code loads the operating manual");
|
|
659
1268
|
}
|
|
660
|
-
const inRepo =
|
|
661
|
-
const gitNote = inRepo ? "existing git repository kept" :
|
|
1269
|
+
const inRepo = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root2, encoding: "utf8" }).stdout?.trim() === "true";
|
|
1270
|
+
const gitNote = inRepo ? "existing git repository kept" : spawnSync2("git", ["init", "-q", "-b", "main"], { cwd: root2 }).status === 0 ? "git initialised" : "git not initialised";
|
|
662
1271
|
const where = relative2(opts.cwd, root2) || ".";
|
|
663
1272
|
console.log(`
|
|
664
1273
|
Set up project "${opts.name}" (${slug}) in ${root2} (core v${GTM_VERSION}; ${gitNote}).`);
|
|
@@ -672,30 +1281,99 @@ Next:
|
|
|
672
1281
|
}
|
|
673
1282
|
|
|
674
1283
|
// src/commands/job.ts
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1284
|
+
import { createHash as createHash4 } from "crypto";
|
|
1285
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1286
|
+
import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
|
|
1287
|
+
import { join as join12 } from "path";
|
|
1288
|
+
import { parse as parse6 } from "yaml";
|
|
1289
|
+
|
|
1290
|
+
// ../core/src/cache.ts
|
|
1291
|
+
import { createHash as createHash2 } from "crypto";
|
|
1292
|
+
|
|
1293
|
+
// ../core/src/stack-env.ts
|
|
1294
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
|
|
1295
|
+
import { homedir } from "os";
|
|
1296
|
+
import { join as join9 } from "path";
|
|
1297
|
+
var STACK_ENV_FILE = process.env.GTM_STACK_ENV ?? join9(homedir(), ".gtm", "stack.env");
|
|
1298
|
+
var STACKS_DIR = process.env.GTM_STACKS_DIR ?? join9(homedir(), ".gtm", "stacks");
|
|
1299
|
+
var projectStackEnvFile = (projectId) => join9(STACKS_DIR, `${projectId}.env`);
|
|
1300
|
+
var projectSecretsFile = (projectId) => join9(STACKS_DIR, `${projectId}.secrets.env`);
|
|
1301
|
+
function readEnvFile(file) {
|
|
1302
|
+
const out = {};
|
|
1303
|
+
if (!existsSync7(file)) return out;
|
|
1304
|
+
for (const line of readFileSync8(file, "utf8").split("\n")) {
|
|
1305
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
1306
|
+
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
1307
|
+
}
|
|
1308
|
+
return out;
|
|
682
1309
|
}
|
|
1310
|
+
function loadStackEnv(file = STACK_ENV_FILE) {
|
|
1311
|
+
if (!existsSync7(file)) return false;
|
|
1312
|
+
for (const [k, v] of Object.entries(readEnvFile(file))) if (process.env[k] === void 0) process.env[k] = v;
|
|
1313
|
+
return true;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ../core/src/local-stack.ts
|
|
1317
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1318
|
+
async function waitFor(what, check, seconds = 120) {
|
|
1319
|
+
for (let i = 0; i < seconds; i++) {
|
|
1320
|
+
if (await check()) return;
|
|
1321
|
+
await sleep(1e3);
|
|
1322
|
+
}
|
|
1323
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
1324
|
+
}
|
|
1325
|
+
async function hatchetReady(url) {
|
|
1326
|
+
try {
|
|
1327
|
+
return (await fetch(`${url}/api/ready`)).ok;
|
|
1328
|
+
} catch {
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
async function createHatchetToken(exec, name) {
|
|
1333
|
+
let tenant = "";
|
|
1334
|
+
await waitFor("hatchet tenant", () => {
|
|
1335
|
+
try {
|
|
1336
|
+
tenant = exec("postgres", ["psql", "-U", "gtm", "-d", "hatchet", "-tAc", `select id from "Tenant" where slug = 'default'`]);
|
|
1337
|
+
} catch {
|
|
1338
|
+
tenant = "";
|
|
1339
|
+
}
|
|
1340
|
+
return /^[0-9a-f-]{36}$/.test(tenant);
|
|
1341
|
+
});
|
|
1342
|
+
let token = "";
|
|
1343
|
+
await waitFor("hatchet token", () => {
|
|
1344
|
+
try {
|
|
1345
|
+
token = exec("hatchet-lite", ["/hatchet-admin", "token", "create", "--config", "/config", "--tenant-id", tenant, "--name", name]).split("\n").at(-1).trim();
|
|
1346
|
+
} catch {
|
|
1347
|
+
token = "";
|
|
1348
|
+
}
|
|
1349
|
+
return /^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(token);
|
|
1350
|
+
}, 60);
|
|
1351
|
+
return token;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ../core/src/adapters/mock-account-source.ts
|
|
1355
|
+
import { createHash as createHash3 } from "crypto";
|
|
1356
|
+
|
|
1357
|
+
// src/commands/validate.ts
|
|
1358
|
+
import { existsSync as existsSync9, readdirSync as readdirSync2, readFileSync as readFileSync10 } from "fs";
|
|
1359
|
+
import { join as join11 } from "path";
|
|
1360
|
+
import { parse as parse5 } from "yaml";
|
|
683
1361
|
|
|
684
1362
|
// src/commands/migrate.ts
|
|
685
|
-
import { existsSync as
|
|
686
|
-
import { join as
|
|
1363
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync4 } from "fs";
|
|
1364
|
+
import { join as join10, relative as relative3 } from "path";
|
|
687
1365
|
import { parseDocument as parseDocument2 } from "yaml";
|
|
688
1366
|
function projectYamlFiles(root2) {
|
|
689
1367
|
const files = [
|
|
690
|
-
{ kind: "project", file:
|
|
691
|
-
{ kind: "icp", file:
|
|
692
|
-
{ kind: "signals", file:
|
|
693
|
-
...subdirs(
|
|
1368
|
+
{ kind: "project", file: join10(root2, "project.yaml") },
|
|
1369
|
+
{ kind: "icp", file: join10(root2, "context", "icp.yaml") },
|
|
1370
|
+
{ kind: "signals", file: join10(root2, "context", "signals.yaml") },
|
|
1371
|
+
...subdirs(join10(root2, "campaigns")).map((c) => ({ kind: "campaign-config", file: join10(root2, "campaigns", c, "config.yaml") }))
|
|
694
1372
|
];
|
|
695
|
-
return files.filter((f) =>
|
|
1373
|
+
return files.filter((f) => existsSync8(f.file));
|
|
696
1374
|
}
|
|
697
1375
|
function pendingRenames(root2) {
|
|
698
|
-
return FILE_RENAMES.filter((r) =>
|
|
1376
|
+
return FILE_RENAMES.filter((r) => existsSync8(join10(root2, r.from)) && !existsSync8(join10(root2, r.to)));
|
|
699
1377
|
}
|
|
700
1378
|
function runMigrate(root2, opts = {}) {
|
|
701
1379
|
let ok = true;
|
|
@@ -704,10 +1382,10 @@ function runMigrate(root2, opts = {}) {
|
|
|
704
1382
|
changed++;
|
|
705
1383
|
console.log(` ${opts.dryRun ? "would rename" : "renamed"} ${from} \u2192 ${to}`);
|
|
706
1384
|
if (opts.dryRun) continue;
|
|
707
|
-
renameSync(
|
|
1385
|
+
renameSync(join10(root2, from), join10(root2, to));
|
|
708
1386
|
if (to.endsWith(".md")) {
|
|
709
|
-
const p =
|
|
710
|
-
writeFileSync4(p,
|
|
1387
|
+
const p = join10(root2, to);
|
|
1388
|
+
writeFileSync4(p, readFileSync9(p, "utf8").replace(new RegExp(`^# ${from.replace(".", "\\.")}`, "m"), `# ${to}`));
|
|
711
1389
|
}
|
|
712
1390
|
}
|
|
713
1391
|
if (opts.dryRun && changed) {
|
|
@@ -716,7 +1394,7 @@ function runMigrate(root2, opts = {}) {
|
|
|
716
1394
|
}
|
|
717
1395
|
for (const { kind, file } of projectYamlFiles(root2)) {
|
|
718
1396
|
const rel = relative3(root2, file);
|
|
719
|
-
const doc = parseDocument2(
|
|
1397
|
+
const doc = parseDocument2(readFileSync9(file, "utf8"));
|
|
720
1398
|
if (doc.errors.length) {
|
|
721
1399
|
console.log(` \u2717 ${rel}: invalid YAML: ${doc.errors[0].message}`);
|
|
722
1400
|
ok = false;
|
|
@@ -743,96 +1421,7 @@ function runMigrate(root2, opts = {}) {
|
|
|
743
1421
|
return ok;
|
|
744
1422
|
}
|
|
745
1423
|
|
|
746
|
-
// src/commands/new-campaign.ts
|
|
747
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
748
|
-
import { join as join8 } from "path";
|
|
749
|
-
import { parse } from "yaml";
|
|
750
|
-
function runNewCampaign(root2, campaign) {
|
|
751
|
-
if (!campaign || !Slug.safeParse(campaign).success) {
|
|
752
|
-
console.error("Usage: gtm new-campaign <campaign-slug>");
|
|
753
|
-
return false;
|
|
754
|
-
}
|
|
755
|
-
const projectFile = join8(root2, "project.yaml");
|
|
756
|
-
if (!existsSync6(projectFile)) {
|
|
757
|
-
console.error("project.yaml not found: run this from the root of a project repo");
|
|
758
|
-
return false;
|
|
759
|
-
}
|
|
760
|
-
const dest = join8(root2, "campaigns", campaign);
|
|
761
|
-
if (existsSync6(dest)) {
|
|
762
|
-
console.error(`campaigns/${campaign} already exists`);
|
|
763
|
-
return false;
|
|
764
|
-
}
|
|
765
|
-
const projectId = parse(readFileSync7(projectFile, "utf8"))?.project_id ?? "TODO";
|
|
766
|
-
copyTemplate(join8(KIT_DIR, "templates", "campaign"), dest, { PROJECT_ID: projectId, CAMPAIGN_ID: campaign, OWNER: process.env.GTM_USER ?? "TODO" });
|
|
767
|
-
mkdirSync3(join8(dest, "runs"), { recursive: true });
|
|
768
|
-
writeFileSync5(join8(dest, "runs", ".gitkeep"), "");
|
|
769
|
-
console.log(`Created campaigns/${campaign}. Branch: git switch -c campaign/${campaign}`);
|
|
770
|
-
return true;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// src/commands/status.ts
|
|
774
|
-
import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync8 } from "fs";
|
|
775
|
-
import { join as join9 } from "path";
|
|
776
|
-
import { parse as parse2 } from "yaml";
|
|
777
|
-
function readYaml(file) {
|
|
778
|
-
try {
|
|
779
|
-
return parse2(readFileSync8(file, "utf8"));
|
|
780
|
-
} catch {
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
function campaignStages(root2) {
|
|
785
|
-
return subdirs(join9(root2, "campaigns")).map((id) => ({ id, stage: readYaml(join9(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
|
|
786
|
-
}
|
|
787
|
-
function runStatus(root2) {
|
|
788
|
-
const project = readYaml(join9(root2, "project.yaml"));
|
|
789
|
-
const icp = readYaml(join9(root2, "context", "icp.yaml"));
|
|
790
|
-
console.log(`${project?.name ?? "?"} (${project?.project_id ?? "?"}) \xB7 status ${project?.status ?? "?"} \xB7 core v${GTM_VERSION}`);
|
|
791
|
-
console.log(`ICP gate: ${icp?.approval?.status ?? "?"}${icp?.approval?.approved_by ? ` by ${icp.approval.approved_by} on ${icp.approval.approved_at}` : ""}
|
|
792
|
-
`);
|
|
793
|
-
const rows = subdirs(join9(root2, "campaigns")).map((id) => {
|
|
794
|
-
const cfg = readYaml(join9(root2, "campaigns", id, "config.yaml")) ?? {};
|
|
795
|
-
const runsDir = join9(root2, "campaigns", id, "runs");
|
|
796
|
-
const last = existsSync7(runsDir) ? readdirSync2(runsDir).filter((f) => f.endsWith(".md")).sort().at(-1) ?? "-" : "-";
|
|
797
|
-
return [id, cfg.stage ?? "?", cfg.gates?.copy?.status ?? "?", cfg.gates?.launch?.status ?? "?", cfg.owner ?? "?", last];
|
|
798
|
-
});
|
|
799
|
-
if (!rows.length) {
|
|
800
|
-
console.log("no campaigns yet: npx gtm new-campaign <campaign-slug>");
|
|
801
|
-
return true;
|
|
802
|
-
}
|
|
803
|
-
const header = ["campaign", "stage", "copy gate", "launch gate", "owner", "latest run log"];
|
|
804
|
-
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
|
|
805
|
-
const line = (r) => r.map((c, i) => String(c).padEnd(widths[i])).join(" ");
|
|
806
|
-
console.log(line(header));
|
|
807
|
-
console.log(widths.map((w) => "-".repeat(w)).join(" "));
|
|
808
|
-
for (const r of rows) console.log(line(r));
|
|
809
|
-
return true;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// src/commands/upgrade.ts
|
|
813
|
-
import { spawnSync as spawnSync2 } from "child_process";
|
|
814
|
-
import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
|
|
815
|
-
import { join as join11 } from "path";
|
|
816
|
-
|
|
817
|
-
// src/changelog.ts
|
|
818
|
-
function compareVersions(a, b) {
|
|
819
|
-
const pa = a.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
820
|
-
const pb = b.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
821
|
-
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
822
|
-
return 0;
|
|
823
|
-
}
|
|
824
|
-
function changelogBetween(text, from, to) {
|
|
825
|
-
const parts = text.split(/^(?=## )/m);
|
|
826
|
-
return parts.filter((p) => {
|
|
827
|
-
const m = p.match(/^## \[?(\d+\.\d+\.\d+)/);
|
|
828
|
-
return m && compareVersions(m[1], from) > 0 && compareVersions(m[1], to) <= 0;
|
|
829
|
-
}).join("").trim();
|
|
830
|
-
}
|
|
831
|
-
|
|
832
1424
|
// src/commands/validate.ts
|
|
833
|
-
import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync9 } from "fs";
|
|
834
|
-
import { join as join10 } from "path";
|
|
835
|
-
import { parse as parse3 } from "yaml";
|
|
836
1425
|
var REQUIRED_CONTEXT = ["intake.md", "icp.md", "icp.yaml", "signals.yaml", "personas.md", "offer.md", "proof.md", "voice.md"];
|
|
837
1426
|
var REQUIRED_CAMPAIGN = ["brief.md", "config.yaml", "copy.md"];
|
|
838
1427
|
var DATA_FILES = /\.(csv|tsv|xlsx?|parquet|jsonl|ndjson)$/i;
|
|
@@ -842,21 +1431,21 @@ var ALLOWED_EMAIL = /@(example\.(com|org|net)|[a-z0-9-]+\.example)$/i;
|
|
|
842
1431
|
var SKIP_DIRS = ["node_modules", ".git", ".gtm"];
|
|
843
1432
|
var stageIndex = (s) => Stage.options.indexOf(s);
|
|
844
1433
|
function readPackageJson(root2) {
|
|
845
|
-
const p =
|
|
846
|
-
return
|
|
1434
|
+
const p = join11(root2, "package.json");
|
|
1435
|
+
return existsSync9(p) ? JSON.parse(readFileSync10(p, "utf8")) : null;
|
|
847
1436
|
}
|
|
848
1437
|
function pinnedVersion(root2) {
|
|
849
1438
|
const pkg = readPackageJson(root2);
|
|
850
1439
|
return pkg?.devDependencies?.[PACKAGE_NAME] ?? pkg?.dependencies?.[PACKAGE_NAME] ?? null;
|
|
851
1440
|
}
|
|
852
1441
|
function loadVersioned(r, file, kind, schema) {
|
|
853
|
-
if (!
|
|
1442
|
+
if (!existsSync9(file)) {
|
|
854
1443
|
r.err(file, "missing");
|
|
855
1444
|
return null;
|
|
856
1445
|
}
|
|
857
1446
|
let raw;
|
|
858
1447
|
try {
|
|
859
|
-
raw =
|
|
1448
|
+
raw = parse5(readFileSync10(file, "utf8"));
|
|
860
1449
|
} catch (e) {
|
|
861
1450
|
r.err(file, `invalid YAML: ${e.message}`);
|
|
862
1451
|
return null;
|
|
@@ -878,22 +1467,22 @@ function loadVersioned(r, file, kind, schema) {
|
|
|
878
1467
|
function checkCoreSync(r, root2) {
|
|
879
1468
|
const pinned = pinnedVersion(root2);
|
|
880
1469
|
if (!pinned) return;
|
|
881
|
-
if (!/^\d+\.\d+\.\d+/.test(pinned)) r.err(
|
|
882
|
-
const versionFile =
|
|
883
|
-
const synced =
|
|
1470
|
+
if (!/^\d+\.\d+\.\d+/.test(pinned)) r.err(join11(root2, "package.json"), `${PACKAGE_NAME} must be pinned exactly (got "${pinned}")`);
|
|
1471
|
+
const versionFile = join11(root2, ".gtm", "VERSION");
|
|
1472
|
+
const synced = existsSync9(versionFile) ? readFileSync10(versionFile, "utf8").trim() : null;
|
|
884
1473
|
if (synced !== GTM_VERSION) r.err(versionFile, `is ${synced ?? "missing"}, installed core is ${GTM_VERSION}: run \`npx gtm sync\``);
|
|
885
|
-
if (!
|
|
1474
|
+
if (!existsSync9(join11(root2, "AGENTS.md"))) r.err(join11(root2, "AGENTS.md"), "missing: run `npx gtm sync`");
|
|
886
1475
|
}
|
|
887
1476
|
function checkNoLeadData(r, root2) {
|
|
888
1477
|
for (const rel of walk(root2, SKIP_DIRS)) {
|
|
889
|
-
const file =
|
|
1478
|
+
const file = join11(root2, rel);
|
|
890
1479
|
if (DATA_FILES.test(rel)) {
|
|
891
1480
|
r.err(file, "data files are not allowed in a project repo (lead data belongs in Postgres)");
|
|
892
1481
|
continue;
|
|
893
1482
|
}
|
|
894
1483
|
if (!/\.(md|ya?ml|txt)$/i.test(rel) || rel === "pnpm-lock.yaml") continue;
|
|
895
|
-
if (/\.ya?ml$/i.test(rel) && /\bTODO\b/.test(
|
|
896
|
-
const found = (
|
|
1484
|
+
if (/\.ya?ml$/i.test(rel) && /\bTODO\b/.test(readFileSync10(file, "utf8"))) r.warn(file, "still contains TODO placeholders");
|
|
1485
|
+
const found = (readFileSync10(file, "utf8").match(EMAIL) ?? []).filter((e) => !ALLOWED_EMAIL.test(e));
|
|
897
1486
|
if (found.length) r.warn(file, `contains email address(es) ${[...new Set(found)].slice(0, 3).join(", ")}: confirm none are prospects`);
|
|
898
1487
|
}
|
|
899
1488
|
}
|
|
@@ -908,11 +1497,24 @@ function checkPinnedApproval(r, contentFile, approval, current, what, approveCmd
|
|
|
908
1497
|
r.err(contentFile, `${what} changed after approval (approved ${pinned}, now ${current}). Re-present it to the approver and record it with \`${approveCmd} --by "<name>"\`, or revert the change`);
|
|
909
1498
|
return false;
|
|
910
1499
|
}
|
|
911
|
-
function
|
|
912
|
-
|
|
1500
|
+
function checkGitHubMode(r, file, what, approval, approver, prApprovals, content) {
|
|
1501
|
+
if (approval.status !== "approved" || !isGitHubHandle(approver)) return true;
|
|
1502
|
+
if (!approval.approved_via) {
|
|
1503
|
+
r.err(file, `${what} approval was recorded without a pull request, but the approver is ${approver} (GitHub mode): get an APPROVED review on a PR and run \`npx gtm approve ... --pr <number>\``);
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1506
|
+
if (!approval.approved_by || login(approval.approved_by) !== login(approver)) {
|
|
1507
|
+
r.err(file, `${what} was approved by ${approval.approved_by}, but the project approver is ${approver}`);
|
|
1508
|
+
return false;
|
|
1509
|
+
}
|
|
1510
|
+
prApprovals.push({ file, what, approval, approver, ...content });
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApproved, approver, prApprovals) {
|
|
1514
|
+
const dir = join11(root2, "campaigns", campaign);
|
|
913
1515
|
console.log(`campaign ${campaign}`);
|
|
914
|
-
for (const f of REQUIRED_CAMPAIGN) if (!
|
|
915
|
-
const cfgFile =
|
|
1516
|
+
for (const f of REQUIRED_CAMPAIGN) if (!existsSync9(join11(dir, f))) r.err(join11(dir, f), "missing");
|
|
1517
|
+
const cfgFile = join11(dir, "config.yaml");
|
|
916
1518
|
const cfg = loadVersioned(r, cfgFile, "campaign-config", CampaignConfig);
|
|
917
1519
|
if (cfg) {
|
|
918
1520
|
if (cfg.project_id !== projectId) r.err(cfgFile, `project_id "${cfg.project_id}" does not match project.yaml "${projectId}"`);
|
|
@@ -929,16 +1531,16 @@ function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApprov
|
|
|
929
1531
|
}
|
|
930
1532
|
const providers = /* @__PURE__ */ new Set([...cfg.enrichment.email_waterfall, cfg.enrichment.verification.provider, cfg.sending.sender]);
|
|
931
1533
|
for (const p of providers) {
|
|
932
|
-
if (!p.startsWith("mock-") && !
|
|
1534
|
+
if (!p.startsWith("mock-") && !existsSync9(join11(KIT_DIR, "providers", `${p}.md`))) r.warn(cfgFile, `no .gtm/providers/${p}.md documenting "${p}"`);
|
|
933
1535
|
}
|
|
934
|
-
if (stageIndex(cfg.stage) > stageIndex("tam") && !
|
|
1536
|
+
if (stageIndex(cfg.stage) > stageIndex("tam") && !existsSync9(join11(root2, "tam", `${cfg.tam_id}.md`))) {
|
|
935
1537
|
r.err(cfgFile, `stage is "${cfg.stage}" but tam/${cfg.tam_id}.md does not exist`);
|
|
936
1538
|
}
|
|
937
1539
|
if (stageIndex(cfg.stage) > stageIndex("icp") && !icpApproved) {
|
|
938
1540
|
r.err(cfgFile, `stage is "${cfg.stage}" but the ICP gate (context/icp.yaml approval) is not approved, or the ICP changed since approval`);
|
|
939
1541
|
}
|
|
940
|
-
const copyFile =
|
|
941
|
-
const copyApproved =
|
|
1542
|
+
const copyFile = join11(dir, "copy.md");
|
|
1543
|
+
const copyApproved = existsSync9(copyFile) ? checkPinnedApproval(r, copyFile, cfg.gates.copy, copyContentHash(readFileSync10(copyFile, "utf8")), "copy", `npx gtm approve copy ${campaign}`) && checkGitHubMode(r, cfgFile, `copy (${campaign})`, cfg.gates.copy, approver, prApprovals, { contentPath: `campaigns/${campaign}/copy.md`, hashOf: copyContentHash }) : false;
|
|
942
1544
|
if (stageIndex(cfg.stage) > stageIndex("copy") && !copyApproved) {
|
|
943
1545
|
r.err(cfgFile, `stage is "${cfg.stage}" but gates.copy is not approved, or copy.md changed since approval`);
|
|
944
1546
|
}
|
|
@@ -948,64 +1550,373 @@ function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApprov
|
|
|
948
1550
|
if (cfg.gates.launch.status === "approved" && !copyApproved) {
|
|
949
1551
|
r.err(cfgFile, "gates.launch is approved but gates.copy is not");
|
|
950
1552
|
}
|
|
951
|
-
if (stageIndex(cfg.stage) > stageIndex("qa") && !
|
|
952
|
-
r.err(
|
|
1553
|
+
if (stageIndex(cfg.stage) > stageIndex("qa") && !existsSync9(join11(dir, "qa.md"))) {
|
|
1554
|
+
r.err(join11(dir, "qa.md"), "missing: QA report is required before launch");
|
|
953
1555
|
}
|
|
954
1556
|
}
|
|
955
|
-
const runs =
|
|
956
|
-
if (
|
|
957
|
-
for (const f of
|
|
958
|
-
if (!RUN_LOG_NAME.test(f)) r.err(
|
|
1557
|
+
const runs = join11(dir, "runs");
|
|
1558
|
+
if (existsSync9(runs)) {
|
|
1559
|
+
for (const f of readdirSync2(runs).filter((f2) => !f2.startsWith("."))) {
|
|
1560
|
+
if (!RUN_LOG_NAME.test(f)) r.err(join11(runs, f), "run logs must be named YYYY-MM-DD-<user>[-n].md");
|
|
959
1561
|
}
|
|
960
1562
|
}
|
|
961
1563
|
}
|
|
962
|
-
function runValidate(root2, onlyCampaign) {
|
|
1564
|
+
async function runValidate(root2, onlyCampaign, opts = {}) {
|
|
963
1565
|
const r = new Report(root2);
|
|
964
1566
|
console.log(`validating ${root2} against core v${GTM_VERSION}`);
|
|
965
1567
|
checkCoreSync(r, root2);
|
|
966
|
-
for (const { from, to } of pendingRenames(root2)) r.err(
|
|
967
|
-
const meta = loadVersioned(r,
|
|
1568
|
+
for (const { from, to } of pendingRenames(root2)) r.err(join11(root2, from), `renamed to ${to} in core 0.3.0: run \`npx gtm migrate\``);
|
|
1569
|
+
const meta = loadVersioned(r, join11(root2, "project.yaml"), "project", ProjectFile);
|
|
968
1570
|
const projectId = meta?.project_id ?? "";
|
|
969
1571
|
if (meta) {
|
|
970
1572
|
for (const key of Object.keys(meta.features)) {
|
|
971
|
-
if (!FEATURES[key]) r.err(
|
|
1573
|
+
if (!FEATURES[key]) r.err(join11(root2, "project.yaml"), `unknown feature "${key}" (known: ${Object.keys(FEATURES).join(", ") || "none"})`);
|
|
972
1574
|
}
|
|
973
1575
|
}
|
|
974
|
-
if (!
|
|
975
|
-
|
|
976
|
-
const
|
|
977
|
-
const
|
|
978
|
-
const
|
|
979
|
-
const
|
|
1576
|
+
if (!existsSync9(join11(root2, "context", "research.md"))) r.warn(join11(root2, "context", "research.md"), "missing: run the research skill (.gtm/skills/research/SKILL.md) before intake questions");
|
|
1577
|
+
if (!existsSync9(join11(root2, "PROJECT.md"))) r.warn(join11(root2, "PROJECT.md"), "missing (project-specific rules for agents)");
|
|
1578
|
+
for (const f of REQUIRED_CONTEXT) if (!existsSync9(join11(root2, "context", f))) r.err(join11(root2, "context", f), "missing");
|
|
1579
|
+
const icpFile = join11(root2, "context", "icp.yaml");
|
|
1580
|
+
const sigFile = join11(root2, "context", "signals.yaml");
|
|
1581
|
+
const icp = existsSync9(icpFile) ? loadVersioned(r, icpFile, "icp", IcpFile) : null;
|
|
1582
|
+
const signals = existsSync9(sigFile) ? loadVersioned(r, sigFile, "signals", SignalsFile) : null;
|
|
980
1583
|
if (meta && icp && icp.project_id !== projectId) r.err(icpFile, `project_id "${icp.project_id}" does not match project.yaml "${projectId}"`);
|
|
981
1584
|
if (meta && signals && signals.project_id !== projectId) r.err(sigFile, `project_id "${signals.project_id}" does not match project.yaml "${projectId}"`);
|
|
982
|
-
const
|
|
1585
|
+
const approver = meta?.owners.approver;
|
|
1586
|
+
const prApprovals = [];
|
|
1587
|
+
const icpApproved = icp ? checkPinnedApproval(r, icpFile, icp.approval, icpContentHash(parse5(readFileSync10(icpFile, "utf8"))), "ICP", "npx gtm approve icp") && checkGitHubMode(r, icpFile, "ICP", icp.approval, approver, prApprovals, { contentPath: "context/icp.yaml", hashOf: (t) => icpContentHash(parse5(t)) }) : false;
|
|
983
1588
|
checkNoLeadData(r, root2);
|
|
984
|
-
const campaigns = onlyCampaign ? [onlyCampaign] : subdirs(
|
|
1589
|
+
const campaigns = onlyCampaign ? [onlyCampaign] : subdirs(join11(root2, "campaigns"));
|
|
985
1590
|
for (const c of campaigns) {
|
|
986
|
-
if (!
|
|
987
|
-
r.err(
|
|
1591
|
+
if (!existsSync9(join11(root2, "campaigns", c))) {
|
|
1592
|
+
r.err(join11(root2, "campaigns", c), "campaign folder not found");
|
|
988
1593
|
continue;
|
|
989
1594
|
}
|
|
990
|
-
validateCampaign(r, root2, projectId, c, icp, signals, icpApproved);
|
|
1595
|
+
validateCampaign(r, root2, projectId, c, icp, signals, icpApproved, approver, prApprovals);
|
|
1596
|
+
}
|
|
1597
|
+
if (opts.verifyGithub && prApprovals.length) {
|
|
1598
|
+
const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
|
|
1599
|
+
if (typeof api === "string") r.err(root2, `--verify-github: ${api}`);
|
|
1600
|
+
else {
|
|
1601
|
+
for (const p of prApprovals) {
|
|
1602
|
+
const ok = await verifyRecordedApproval(api, p.approval.approved_via, p.approver, p.contentPath, p.approval.content_hash, p.hashOf);
|
|
1603
|
+
if (ok === true) console.log(` \u2713 ${p.what}: ${p.approver} approved ${p.approval.approved_via}`);
|
|
1604
|
+
else r.err(p.file, `${p.what}: ${ok}`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
991
1607
|
}
|
|
992
1608
|
r.summary();
|
|
993
1609
|
return r.errors === 0;
|
|
994
1610
|
}
|
|
995
1611
|
|
|
1612
|
+
// src/commands/job.ts
|
|
1613
|
+
var PLANNED = ["enrich", "suppression:import", "signals", "qualify", "qa", "push", "activate", "results"];
|
|
1614
|
+
function git(root2, args2) {
|
|
1615
|
+
const res = spawnSync3("git", args2, { cwd: root2, encoding: "utf8" });
|
|
1616
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
1617
|
+
}
|
|
1618
|
+
function buildTamInput(root2, campaign) {
|
|
1619
|
+
const read = (rel) => parse6(readFileSync11(join12(root2, rel), "utf8"));
|
|
1620
|
+
const project = ProjectFile.parse(read("project.yaml"));
|
|
1621
|
+
const icp = IcpFile.parse(read("context/icp.yaml"));
|
|
1622
|
+
const cfgText = readFileSync11(join12(root2, "campaigns", campaign, "config.yaml"), "utf8");
|
|
1623
|
+
const cfg = CampaignConfig.parse(parse6(cfgText));
|
|
1624
|
+
if (icp.approval.status !== "approved") return "TAM needs an approved ICP (hard stop, AGENTS.md section 4). Get ICP sign-off first.";
|
|
1625
|
+
if (Stage.options.indexOf(cfg.stage) < Stage.options.indexOf("tam")) return `campaign stage is "${cfg.stage}": move it to "tam" first.`;
|
|
1626
|
+
if (!cfg.tam) return "config.yaml has no `tam:` section. Add `tam: { source: mock-accounts }` for a local proof, or a real account source.";
|
|
1627
|
+
const segments = resolveIcp(icp).segments.filter((s) => cfg.segments.includes(s.id)).map(({ id, description, firmographics }) => ({ id, description, firmographics }));
|
|
1628
|
+
return TamJobInput.parse({
|
|
1629
|
+
ctx: {
|
|
1630
|
+
project_id: project.project_id,
|
|
1631
|
+
campaign_id: campaign,
|
|
1632
|
+
triggered_by: process.env.GTM_USER ?? git(root2, ["config", "user.name"]) ?? "unknown",
|
|
1633
|
+
core_version: GTM_VERSION,
|
|
1634
|
+
git_sha: git(root2, ["rev-parse", "HEAD"]),
|
|
1635
|
+
config_hash: `sha256:${createHash4("sha256").update(cfgText).digest("hex").slice(0, 16)}`
|
|
1636
|
+
},
|
|
1637
|
+
tam_id: cfg.tam_id,
|
|
1638
|
+
source: cfg.tam.source,
|
|
1639
|
+
max_accounts_per_segment: cfg.tam.max_accounts_per_segment,
|
|
1640
|
+
segments,
|
|
1641
|
+
exclusions: icp.exclusions,
|
|
1642
|
+
budget_usd: cfg.budget.max_total
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
function printTam(res, campaign) {
|
|
1646
|
+
const header = ["segment", "found", "excluded", "new", "updated", "reachable", "active"];
|
|
1647
|
+
const rows = res.segments.map((s) => [s.segment_id, s.found, s.excluded, s.inserted, s.updated, s.reachable, s.active].map(String));
|
|
1648
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
1649
|
+
const line = (r) => r.map((c, i) => i === 0 ? c.padEnd(widths[i]) : c.padStart(widths[i])).join(" ");
|
|
1650
|
+
console.log(`
|
|
1651
|
+
${line(header)}
|
|
1652
|
+
${widths.map((w) => "-".repeat(w)).join(" ")}`);
|
|
1653
|
+
for (const r of rows) console.log(line(r));
|
|
1654
|
+
console.log(`
|
|
1655
|
+
Funnel: TAM ${res.totals.tam} \u2192 reachable ${res.totals.reachable} \u2192 active ${res.totals.active} cost $${res.cost_usd.toFixed(2)} run ${res.run_id}`);
|
|
1656
|
+
console.log(`
|
|
1657
|
+
Run log row (campaigns/${campaign}/runs/\u2026):
|
|
1658
|
+
| tam | ${res.run_id} | succeeded | ${res.segments.length} segment(s) | TAM ${res.totals.tam} \u2192 reachable ${res.totals.reachable} \u2192 active ${res.totals.active} | ${res.cost_usd.toFixed(2)} |`);
|
|
1659
|
+
}
|
|
1660
|
+
async function runJob(root2, args2) {
|
|
1661
|
+
const [name, campaign] = args2;
|
|
1662
|
+
if (name && PLANNED.includes(name)) {
|
|
1663
|
+
console.error(`gtm job ${name}: not available yet (planned). Per AGENTS.md this is a mandatory stop: do not improvise it with scripts.`);
|
|
1664
|
+
return false;
|
|
1665
|
+
}
|
|
1666
|
+
if (name !== "tam" || !campaign) {
|
|
1667
|
+
console.error(`Usage: gtm job tam <campaign> (planned: ${PLANNED.join(", ")})`);
|
|
1668
|
+
return false;
|
|
1669
|
+
}
|
|
1670
|
+
if (!existsSync10(join12(root2, "campaigns", campaign, "config.yaml"))) {
|
|
1671
|
+
console.error(`campaigns/${campaign}/config.yaml not found`);
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
console.log(`\u2022 validating ${campaign}`);
|
|
1675
|
+
if (!await runValidate(root2, campaign)) {
|
|
1676
|
+
console.error("\nFix the validation errors above before running jobs.");
|
|
1677
|
+
return false;
|
|
1678
|
+
}
|
|
1679
|
+
let input;
|
|
1680
|
+
try {
|
|
1681
|
+
input = buildTamInput(root2, campaign);
|
|
1682
|
+
} catch (e) {
|
|
1683
|
+
console.error(e.message);
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
if (typeof input === "string") {
|
|
1687
|
+
console.error(input);
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
loadStackEnv(projectStackEnvFile(input.ctx.project_id));
|
|
1691
|
+
loadStackEnv();
|
|
1692
|
+
if (!process.env.HATCHET_CLIENT_TOKEN) {
|
|
1693
|
+
console.error(`No Hatchet connection. Run \`npx gtm stack up\` in this project repo (writes ${projectStackEnvFile(input.ctx.project_id)}). Deployed: set HATCHET_CLIENT_TOKEN.`);
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
const { HatchetClient } = await import("@hatchet-dev/typescript-sdk/v1");
|
|
1697
|
+
const hatchet = HatchetClient.init();
|
|
1698
|
+
const tam = hatchet.task({ name: JOB_NAMES.tam, fn: async () => {
|
|
1699
|
+
throw new Error("runs on the gtm worker");
|
|
1700
|
+
} });
|
|
1701
|
+
console.log(`\u2022 running tam for ${input.segments.length} segment(s) via ${input.source} (budget $${input.budget_usd})`);
|
|
1702
|
+
const hint = setTimeout(() => console.log(" \u2026 still waiting. Is the worker running? (`npx gtm stack status`)"), 2e4);
|
|
1703
|
+
try {
|
|
1704
|
+
const output = await tam.run(input);
|
|
1705
|
+
printTam(TamJobResult.parse(output), campaign);
|
|
1706
|
+
return true;
|
|
1707
|
+
} catch (e) {
|
|
1708
|
+
console.error(`tam failed: ${e.message}`);
|
|
1709
|
+
return false;
|
|
1710
|
+
} finally {
|
|
1711
|
+
clearTimeout(hint);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// src/commands/new-campaign.ts
|
|
1716
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
|
|
1717
|
+
import { join as join13 } from "path";
|
|
1718
|
+
import { parse as parse7 } from "yaml";
|
|
1719
|
+
function runNewCampaign(root2, campaign) {
|
|
1720
|
+
if (!campaign || !Slug.safeParse(campaign).success) {
|
|
1721
|
+
console.error("Usage: gtm new-campaign <campaign-slug>");
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
const projectFile = join13(root2, "project.yaml");
|
|
1725
|
+
if (!existsSync11(projectFile)) {
|
|
1726
|
+
console.error("project.yaml not found: run this from the root of a project repo");
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1729
|
+
const dest = join13(root2, "campaigns", campaign);
|
|
1730
|
+
if (existsSync11(dest)) {
|
|
1731
|
+
console.error(`campaigns/${campaign} already exists`);
|
|
1732
|
+
return false;
|
|
1733
|
+
}
|
|
1734
|
+
const projectId = parse7(readFileSync12(projectFile, "utf8"))?.project_id ?? "TODO";
|
|
1735
|
+
copyTemplate(join13(KIT_DIR, "templates", "campaign"), dest, { PROJECT_ID: projectId, CAMPAIGN_ID: campaign, OWNER: process.env.GTM_USER ?? "TODO" });
|
|
1736
|
+
mkdirSync3(join13(dest, "runs"), { recursive: true });
|
|
1737
|
+
writeFileSync5(join13(dest, "runs", ".gitkeep"), "");
|
|
1738
|
+
console.log(`Created campaigns/${campaign}. Branch: git switch -c campaign/${campaign}`);
|
|
1739
|
+
return true;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/commands/stack.ts
|
|
1743
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
1744
|
+
import { chmodSync, existsSync as existsSync12, mkdirSync as mkdirSync4, readFileSync as readFileSync13, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
1745
|
+
import { join as join14 } from "path";
|
|
1746
|
+
import { parse as parse8 } from "yaml";
|
|
1747
|
+
var WORKER_IMAGE = "ghcr.io/veleads/gtm-worker";
|
|
1748
|
+
var PORTS = { GTM_PG_PORT: 5432, GTM_HATCHET_UI_PORT: 8888, GTM_HATCHET_GRPC_PORT: 7077 };
|
|
1749
|
+
function stackSettings(projectId, env = process.env) {
|
|
1750
|
+
const envFile = projectStackEnvFile(projectId);
|
|
1751
|
+
const saved = readEnvFile(envFile);
|
|
1752
|
+
const ports = Object.fromEntries(
|
|
1753
|
+
Object.keys(PORTS).map((k) => [k, Number(env[k] ?? saved[k] ?? PORTS[k])])
|
|
1754
|
+
);
|
|
1755
|
+
for (const [k, v] of Object.entries(ports)) if (!Number.isInteger(v) || v < 1 || v > 65535) throw new Error(`${k} must be a port number, got "${v}"`);
|
|
1756
|
+
return {
|
|
1757
|
+
projectId,
|
|
1758
|
+
composeProject: `gtm-${projectId}`,
|
|
1759
|
+
composeFile: join14(KIT_DIR, "stack", "docker-compose.yml"),
|
|
1760
|
+
envFile,
|
|
1761
|
+
secretsFile: projectSecretsFile(projectId),
|
|
1762
|
+
workerImage: env.GTM_WORKER_IMAGE ?? `${WORKER_IMAGE}:${GTM_VERSION}`,
|
|
1763
|
+
ports
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
function composeEnv(s, token = "") {
|
|
1767
|
+
return {
|
|
1768
|
+
...Object.fromEntries(Object.entries(s.ports).map(([k, v]) => [k, String(v)])),
|
|
1769
|
+
GTM_WORKER_IMAGE: s.workerImage,
|
|
1770
|
+
GTM_SECRETS_FILE: s.secretsFile,
|
|
1771
|
+
HATCHET_CLIENT_TOKEN: token
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
function stackEnvText(s, token) {
|
|
1775
|
+
return [
|
|
1776
|
+
`# Written by \`gtm stack up\` for project ${s.projectId}. Local stack only; never commit.`,
|
|
1777
|
+
`DATABASE_URL=postgresql://gtm:gtm@localhost:${s.ports.GTM_PG_PORT}/gtm`,
|
|
1778
|
+
`HATCHET_CLIENT_TOKEN=${token}`,
|
|
1779
|
+
"HATCHET_CLIENT_TLS_STRATEGY=none",
|
|
1780
|
+
...Object.entries(s.ports).map(([k, v]) => `${k}=${v}`),
|
|
1781
|
+
""
|
|
1782
|
+
].join("\n");
|
|
1783
|
+
}
|
|
1784
|
+
function compose(s, args2, opts = {}) {
|
|
1785
|
+
const res = spawnSync4("docker", ["compose", "-f", s.composeFile, "-p", s.composeProject, ...args2], {
|
|
1786
|
+
env: { ...process.env, ...composeEnv(s, opts.token) },
|
|
1787
|
+
encoding: "utf8",
|
|
1788
|
+
stdio: opts.quiet ? "pipe" : ["ignore", "inherit", "inherit"]
|
|
1789
|
+
});
|
|
1790
|
+
if (res.error) throw new Error(`docker is not available (${res.error.message}). Install Docker Desktop or Docker Engine first.`);
|
|
1791
|
+
if (res.status !== 0) throw new Error(`docker compose ${args2.join(" ")} failed${opts.quiet ? `: ${res.stderr || res.stdout}` : ""}`);
|
|
1792
|
+
return (res.stdout ?? "").trim();
|
|
1793
|
+
}
|
|
1794
|
+
async function up(s) {
|
|
1795
|
+
const ui = `http://localhost:${s.ports.GTM_HATCHET_UI_PORT}`;
|
|
1796
|
+
console.log(`\u2022 starting postgres + hatchet-lite (${s.composeProject})`);
|
|
1797
|
+
compose(s, ["up", "-d", "--wait", "postgres"]);
|
|
1798
|
+
compose(s, ["up", "-d", "hatchet-lite"]);
|
|
1799
|
+
await waitFor("hatchet-lite", () => hatchetReady(ui));
|
|
1800
|
+
console.log("\u2022 creating a Hatchet API token");
|
|
1801
|
+
const token = await createHatchetToken((service, args2) => compose(s, ["exec", "-T", service, ...args2], { quiet: true }), `gtm-${s.projectId}-${Date.now()}`);
|
|
1802
|
+
mkdirSync4(STACKS_DIR, { recursive: true, mode: 448 });
|
|
1803
|
+
writeFileSync6(s.envFile, stackEnvText(s, token));
|
|
1804
|
+
chmodSync(s.envFile, 384);
|
|
1805
|
+
console.log(`\u2022 starting the worker (${s.workerImage})`);
|
|
1806
|
+
compose(s, ["up", "-d", "worker"], { token });
|
|
1807
|
+
console.log(`
|
|
1808
|
+
Stack for ${s.projectId} is up.
|
|
1809
|
+
Hatchet UI ${ui} (local login admin@example.com / Admin123!!)
|
|
1810
|
+
Postgres postgresql://gtm:gtm@localhost:${s.ports.GTM_PG_PORT}/gtm (this project's database)
|
|
1811
|
+
Settings ${s.envFile} (used by \`npx gtm job\`)
|
|
1812
|
+
Secrets ${s.secretsFile} (provider API keys for the worker; ${existsSync12(s.secretsFile) ? "found" : "not created yet"})
|
|
1813
|
+
Next: \`npx gtm job tam <campaign>\`.`);
|
|
1814
|
+
}
|
|
1815
|
+
function down(s, wipe) {
|
|
1816
|
+
compose(s, ["down", ...wipe ? ["-v"] : []]);
|
|
1817
|
+
if (wipe) rmSync2(s.envFile, { force: true });
|
|
1818
|
+
console.log(wipe ? `Stack stopped; this project's database, Hatchet state and ${s.envFile} are deleted (secrets file kept).` : "Stack stopped; data kept (`npx gtm stack up` to resume).");
|
|
1819
|
+
}
|
|
1820
|
+
async function status(s) {
|
|
1821
|
+
compose(s, ["ps"]);
|
|
1822
|
+
const ready = await hatchetReady(`http://localhost:${s.ports.GTM_HATCHET_UI_PORT}`);
|
|
1823
|
+
console.log(`hatchet: ${ready ? "ready" : "not ready"} \xB7 settings: ${existsSync12(s.envFile) ? s.envFile : "missing (run `npx gtm stack up`)"} \xB7 worker image: ${s.workerImage}`);
|
|
1824
|
+
}
|
|
1825
|
+
async function runStack(root2, args2, opts = {}) {
|
|
1826
|
+
const [action] = args2;
|
|
1827
|
+
if (!action || !["up", "down", "status"].includes(action)) {
|
|
1828
|
+
console.error("Usage: gtm stack up | down [--wipe] | status");
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
if (!existsSync12(join14(root2, "project.yaml"))) {
|
|
1832
|
+
console.error("project.yaml not found: run gtm stack inside a project repo.");
|
|
1833
|
+
return false;
|
|
1834
|
+
}
|
|
1835
|
+
try {
|
|
1836
|
+
const project = ProjectFile.parse(parse8(readFileSync13(join14(root2, "project.yaml"), "utf8")));
|
|
1837
|
+
const s = stackSettings(project.project_id);
|
|
1838
|
+
if (action === "up") await up(s);
|
|
1839
|
+
else if (action === "down") down(s, Boolean(opts.wipe));
|
|
1840
|
+
else await status(s);
|
|
1841
|
+
return true;
|
|
1842
|
+
} catch (e) {
|
|
1843
|
+
console.error(`gtm stack ${action}: ${e.message}`);
|
|
1844
|
+
return false;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// src/commands/status.ts
|
|
1849
|
+
import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync14 } from "fs";
|
|
1850
|
+
import { join as join15 } from "path";
|
|
1851
|
+
import { parse as parse9 } from "yaml";
|
|
1852
|
+
function readYaml2(file) {
|
|
1853
|
+
try {
|
|
1854
|
+
return parse9(readFileSync14(file, "utf8"));
|
|
1855
|
+
} catch {
|
|
1856
|
+
return null;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
function campaignStages(root2) {
|
|
1860
|
+
return subdirs(join15(root2, "campaigns")).map((id) => ({ id, stage: readYaml2(join15(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
|
|
1861
|
+
}
|
|
1862
|
+
function runStatus(root2) {
|
|
1863
|
+
const project = readYaml2(join15(root2, "project.yaml"));
|
|
1864
|
+
const icp = readYaml2(join15(root2, "context", "icp.yaml"));
|
|
1865
|
+
console.log(`${project?.name ?? "?"} (${project?.project_id ?? "?"}) \xB7 status ${project?.status ?? "?"} \xB7 core v${GTM_VERSION}`);
|
|
1866
|
+
console.log(`ICP gate: ${icp?.approval?.status ?? "?"}${icp?.approval?.approved_by ? ` by ${icp.approval.approved_by} on ${icp.approval.approved_at}` : ""}
|
|
1867
|
+
`);
|
|
1868
|
+
const rows = subdirs(join15(root2, "campaigns")).map((id) => {
|
|
1869
|
+
const cfg = readYaml2(join15(root2, "campaigns", id, "config.yaml")) ?? {};
|
|
1870
|
+
const runsDir = join15(root2, "campaigns", id, "runs");
|
|
1871
|
+
const last = existsSync13(runsDir) ? readdirSync3(runsDir).filter((f) => f.endsWith(".md")).sort().at(-1) ?? "-" : "-";
|
|
1872
|
+
return [id, cfg.stage ?? "?", cfg.gates?.copy?.status ?? "?", cfg.gates?.launch?.status ?? "?", cfg.owner ?? "?", last];
|
|
1873
|
+
});
|
|
1874
|
+
if (!rows.length) {
|
|
1875
|
+
console.log("no campaigns yet: npx gtm new-campaign <campaign-slug>");
|
|
1876
|
+
return true;
|
|
1877
|
+
}
|
|
1878
|
+
const header = ["campaign", "stage", "copy gate", "launch gate", "owner", "latest run log"];
|
|
1879
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
|
|
1880
|
+
const line = (r) => r.map((c, i) => String(c).padEnd(widths[i])).join(" ");
|
|
1881
|
+
console.log(line(header));
|
|
1882
|
+
console.log(widths.map((w) => "-".repeat(w)).join(" "));
|
|
1883
|
+
for (const r of rows) console.log(line(r));
|
|
1884
|
+
return true;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// src/commands/upgrade.ts
|
|
1888
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
1889
|
+
import { existsSync as existsSync14, readFileSync as readFileSync15 } from "fs";
|
|
1890
|
+
import { join as join16 } from "path";
|
|
1891
|
+
|
|
1892
|
+
// src/changelog.ts
|
|
1893
|
+
function compareVersions(a, b) {
|
|
1894
|
+
const pa = a.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
1895
|
+
const pb = b.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
1896
|
+
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1897
|
+
return 0;
|
|
1898
|
+
}
|
|
1899
|
+
function changelogBetween(text, from, to) {
|
|
1900
|
+
const parts = text.split(/^(?=## )/m);
|
|
1901
|
+
return parts.filter((p) => {
|
|
1902
|
+
const m = p.match(/^## \[?(\d+\.\d+\.\d+)/);
|
|
1903
|
+
return m && compareVersions(m[1], from) > 0 && compareVersions(m[1], to) <= 0;
|
|
1904
|
+
}).join("").trim();
|
|
1905
|
+
}
|
|
1906
|
+
|
|
996
1907
|
// src/commands/upgrade.ts
|
|
997
1908
|
function detectPackageManager(root2) {
|
|
998
|
-
if (
|
|
999
|
-
if (
|
|
1909
|
+
if (existsSync14(join16(root2, "pnpm-lock.yaml"))) return "pnpm";
|
|
1910
|
+
if (existsSync14(join16(root2, "yarn.lock"))) return "yarn";
|
|
1000
1911
|
return "npm";
|
|
1001
1912
|
}
|
|
1002
1913
|
function installExact(root2, pm, spec) {
|
|
1003
|
-
const onPath = (bin) =>
|
|
1914
|
+
const onPath = (bin) => spawnSync5(bin, ["--version"], { stdio: "ignore" }).status === 0;
|
|
1004
1915
|
const [cmd, args2] = pm === "npm" ? ["npm", ["install", "--save-dev", "--save-exact", spec]] : pm === "pnpm" ? onPath("pnpm") ? ["pnpm", ["add", "-D", "--save-exact", spec]] : ["npx", ["-y", "pnpm@10", "add", "-D", "--save-exact", spec]] : onPath("yarn") ? ["yarn", ["add", "-D", "--exact", spec]] : ["npx", ["-y", "yarn@1", "add", "-D", "--exact", spec]];
|
|
1005
|
-
return
|
|
1916
|
+
return spawnSync5(cmd, args2, { cwd: root2, stdio: "inherit" }).status ?? 1;
|
|
1006
1917
|
}
|
|
1007
1918
|
function gtm(root2, args2) {
|
|
1008
|
-
return
|
|
1919
|
+
return spawnSync5("npx", ["gtm", ...args2], { cwd: root2, stdio: "inherit" }).status ?? 1;
|
|
1009
1920
|
}
|
|
1010
1921
|
var FROZEN_STAGES = ["qa", "launch"];
|
|
1011
1922
|
function runUpgrade(root2, opts) {
|
|
@@ -1020,7 +1931,7 @@ function runUpgrade(root2, opts) {
|
|
|
1020
1931
|
console.error("Finish the launch first, or re-run with --force if a GTM engineer approved it.");
|
|
1021
1932
|
return false;
|
|
1022
1933
|
}
|
|
1023
|
-
const dirty =
|
|
1934
|
+
const dirty = spawnSync5("git", ["status", "--porcelain"], { cwd: root2, encoding: "utf8" });
|
|
1024
1935
|
if (dirty.status === 0 && dirty.stdout.trim() && !opts.force) {
|
|
1025
1936
|
console.error("Refusing to upgrade with uncommitted changes. Commit or stash first (or --force).");
|
|
1026
1937
|
return false;
|
|
@@ -1033,9 +1944,9 @@ function runUpgrade(root2, opts) {
|
|
|
1033
1944
|
if (gtm(root2, ["migrate"]) !== 0) return false;
|
|
1034
1945
|
const valid = gtm(root2, ["validate"]) === 0;
|
|
1035
1946
|
const after = pinnedVersion(root2) ?? target;
|
|
1036
|
-
const changelog =
|
|
1037
|
-
if (
|
|
1038
|
-
const notes = changelogBetween(
|
|
1947
|
+
const changelog = join16(root2, "node_modules", ...PACKAGE_NAME.split("/"), "CHANGELOG.md");
|
|
1948
|
+
if (existsSync14(changelog)) {
|
|
1949
|
+
const notes = changelogBetween(readFileSync15(changelog, "utf8"), before, after);
|
|
1039
1950
|
console.log(`
|
|
1040
1951
|
=== Changes ${before} \u2192 ${after} ===
|
|
1041
1952
|
${notes || "(no changelog entries)"}
|
|
@@ -1070,16 +1981,18 @@ var COMMANDS = {
|
|
|
1070
1981
|
examples: ["git switch -c campaign/q1-uk-cfo && npx gtm new-campaign q1-uk-cfo"]
|
|
1071
1982
|
},
|
|
1072
1983
|
validate: {
|
|
1073
|
-
usage: "gtm validate [campaign]",
|
|
1984
|
+
usage: "gtm validate [campaign] [--verify-github]",
|
|
1074
1985
|
summary: "check repo against schemas, gates, data rules",
|
|
1075
1986
|
details: [
|
|
1076
1987
|
"Checks project.yaml, context/ and campaigns against the schemas; segment/persona/signal references;",
|
|
1077
1988
|
"gate order (no stage past an unapproved or changed ICP/copy approval); required files; run-log names;",
|
|
1078
1989
|
"no lead data (blocks data files, warns on email addresses); .gtm/ matches the installed core.",
|
|
1990
|
+
"GitHub mode: ICP/copy approvals must come from the approver's PR review (approved_via).",
|
|
1991
|
+
"--verify-github (CI) re-checks each recorded PR approval on GitHub.",
|
|
1079
1992
|
"Exit code 1 if there is any error; warnings never fail."
|
|
1080
1993
|
],
|
|
1081
1994
|
writes: "Nothing (read-only).",
|
|
1082
|
-
examples: ["npx gtm validate", "npx gtm validate q1-uk-cfo"]
|
|
1995
|
+
examples: ["npx gtm validate", "npx gtm validate q1-uk-cfo", "npx gtm validate --verify-github"]
|
|
1083
1996
|
},
|
|
1084
1997
|
status: {
|
|
1085
1998
|
usage: "gtm status",
|
|
@@ -1088,26 +2001,64 @@ var COMMANDS = {
|
|
|
1088
2001
|
writes: "Nothing (read-only).",
|
|
1089
2002
|
examples: ["npx gtm status"]
|
|
1090
2003
|
},
|
|
2004
|
+
icp: {
|
|
2005
|
+
usage: "gtm icp",
|
|
2006
|
+
summary: "show the ICP as jobs see it (core + segment overrides)",
|
|
2007
|
+
details: [
|
|
2008
|
+
"Prints the core profile, then every segment's FINAL filters (core with the segment's fields",
|
|
2009
|
+
"replacing the same core fields), which fields the segment overrides, its personas, and the",
|
|
2010
|
+
"shared exclusions. Use it to review an ICP before sign-off and to check what `gtm job tam` searches."
|
|
2011
|
+
],
|
|
2012
|
+
writes: "Nothing (read-only).",
|
|
2013
|
+
examples: ["npx gtm icp"]
|
|
2014
|
+
},
|
|
1091
2015
|
approve: {
|
|
1092
|
-
usage: 'gtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]\ngtm approve copy <campaign> --by "<Name (role)>" [
|
|
2016
|
+
usage: 'gtm approve icp --pr <number> [--notes ".."]\ngtm approve copy <campaign> --pr <number> [--notes ".."]\ngtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]\ngtm approve copy <campaign> --by "<Name (role)>" [..]',
|
|
1093
2017
|
summary: "record a sign-off and pin a hash of the approved content",
|
|
1094
2018
|
details: [
|
|
1095
|
-
"
|
|
1096
|
-
"
|
|
1097
|
-
"
|
|
1098
|
-
"
|
|
2019
|
+
"GitHub mode (project.yaml owners.approver is an @handle): the approval IS the approver's APPROVED",
|
|
2020
|
+
"review on a pull request. --pr verifies on GitHub that the PR changes the file, the approver approved",
|
|
2021
|
+
"its latest commit, and your local file equals the approved version; then records approved_via.",
|
|
2022
|
+
"Recorded mode (approver is a name): run ONLY after the named human explicitly approved in this session.",
|
|
2023
|
+
"Either way a content hash is pinned: if the ICP or copy changes later, `gtm validate` fails.",
|
|
2024
|
+
"The launch gate is recorded by hand in config.yaml."
|
|
1099
2025
|
],
|
|
1100
2026
|
writes: "context/icp.yaml \u2192 approval (icp), or campaigns/<campaign>/config.yaml \u2192 gates.copy (copy).",
|
|
1101
|
-
examples: [
|
|
2027
|
+
examples: ["npx gtm approve icp --pr 12", "npx gtm approve copy q1-uk-cfo --pr 15", 'npx gtm approve icp --by "Maria Chen (VP Marketing)"']
|
|
2028
|
+
},
|
|
2029
|
+
track: {
|
|
2030
|
+
usage: "gtm track stage <campaign>\ngtm track gate-open <icp|copy|launch> [campaign] [--pr <number>]\ngtm track gate-done <icp|copy|launch> [campaign]",
|
|
2031
|
+
summary: "mirror stages and gates into the project tracker",
|
|
2032
|
+
details: [
|
|
2033
|
+
"Uses project.yaml \u2192 tracker.kind (github | none). GitHub: one issue per campaign (label stage:<stage>)",
|
|
2034
|
+
"and one per gate, assigned to the approver. Re-running updates the same issue.",
|
|
2035
|
+
"The tracker is a mirror only: stage and gates live in the YAML files. gate-done refuses unless the",
|
|
2036
|
+
"files say the gate is approved."
|
|
2037
|
+
],
|
|
2038
|
+
writes: "Issues in the tracker (GitHub: issues, labels, comments). No local files.",
|
|
2039
|
+
examples: ["npx gtm track stage q1-uk-cfo", "npx gtm track gate-open icp --pr 12", "npx gtm track gate-done copy q1-uk-cfo"]
|
|
2040
|
+
},
|
|
2041
|
+
github: {
|
|
2042
|
+
usage: "gtm github setup [--apply]",
|
|
2043
|
+
summary: "prepare the GitHub repo: labels and approval ruleset",
|
|
2044
|
+
details: [
|
|
2045
|
+
"Creates the labels gtm track uses, and a ruleset on the default branch: PR required, review from",
|
|
2046
|
+
"CODEOWNERS required (the approver owns icp.yaml, signals.yaml, copy.md), CI job `check` must pass.",
|
|
2047
|
+
"Without --apply it only prints the plan. Needs admin rights; private repos need a paid GitHub plan",
|
|
2048
|
+
"for rulesets (CI verification via `validate --verify-github` works on any plan)."
|
|
2049
|
+
],
|
|
2050
|
+
writes: "With --apply: labels and a ruleset on GitHub. Without: nothing.",
|
|
2051
|
+
examples: ["npx gtm github setup", "npx gtm github setup --apply"]
|
|
1102
2052
|
},
|
|
1103
2053
|
sync: {
|
|
1104
2054
|
usage: "gtm sync [--check]",
|
|
1105
2055
|
summary: "copy core kit into .gtm/ + AGENTS.md",
|
|
1106
2056
|
details: [
|
|
1107
|
-
"Makes .gtm/ (skills, templates, schemas, provider docs) and AGENTS.md match the installed core
|
|
2057
|
+
"Makes .gtm/ (skills, templates, schemas, guides, provider docs) and AGENTS.md match the installed core,",
|
|
2058
|
+
"and maintains the gtm block in .github/CODEOWNERS from project.yaml \u2192 owners.approver.",
|
|
1108
2059
|
"--check only compares and exits 1 if they differ (CI uses this; never edit .gtm/ by hand)."
|
|
1109
2060
|
],
|
|
1110
|
-
writes: ".gtm/ (replaced entirely)
|
|
2061
|
+
writes: ".gtm/ (replaced entirely), AGENTS.md, the gtm block in .github/CODEOWNERS; creates CLAUDE.md if missing. With --check: nothing.",
|
|
1111
2062
|
examples: ["npx gtm sync", "npx gtm sync --check"]
|
|
1112
2063
|
},
|
|
1113
2064
|
migrate: {
|
|
@@ -1128,14 +2079,30 @@ var COMMANDS = {
|
|
|
1128
2079
|
examples: ["npx gtm upgrade", "npx gtm upgrade 0.3.0"]
|
|
1129
2080
|
},
|
|
1130
2081
|
job: {
|
|
1131
|
-
usage: "gtm job
|
|
1132
|
-
summary: "run a pipeline job
|
|
2082
|
+
usage: "gtm job tam <campaign>",
|
|
2083
|
+
summary: "run a pipeline job on the workers",
|
|
1133
2084
|
details: [
|
|
1134
|
-
"
|
|
1135
|
-
"
|
|
2085
|
+
"tam: builds the TAM for the campaign's segments into the database (needs an approved ICP and",
|
|
2086
|
+
"config.yaml \u2192 tam.source; mock-accounts for a local proof) and prints the funnel, cost and run_id.",
|
|
2087
|
+
"Validates first, sends the resolved config to the workers via Hatchet, and waits for the result.",
|
|
2088
|
+
"Needs this project's stack: `npx gtm stack up` (settings in ~/.gtm/stacks/<project_id>.env).",
|
|
2089
|
+
"Planned (exit 1, a stop per AGENTS.md): enrich, suppression:import, signals, qualify, qa, push, activate, results."
|
|
1136
2090
|
],
|
|
1137
|
-
writes: "
|
|
2091
|
+
writes: "Rows in Postgres (runs, accounts, costs). No repo files: copy the printed run-log row into the run log.",
|
|
1138
2092
|
examples: ["npx gtm job tam q1-uk-cfo"]
|
|
2093
|
+
},
|
|
2094
|
+
stack: {
|
|
2095
|
+
usage: "gtm stack up\ngtm stack down [--wipe]\ngtm stack status",
|
|
2096
|
+
summary: "run this project's own stack (Postgres + Hatchet + worker) on Docker",
|
|
2097
|
+
details: [
|
|
2098
|
+
"One stack per project (compose project gtm-<project_id>, its own volumes and database). The worker image",
|
|
2099
|
+
"matches the core version this repo pins and applies database migrations when it starts. Needs Docker.",
|
|
2100
|
+
"Ports: GTM_PG_PORT (5432), GTM_HATCHET_UI_PORT (8888), GTM_HATCHET_GRPC_PORT (7077); set them to run two stacks at once.",
|
|
2101
|
+
"Provider API keys for the worker: ~/.gtm/stacks/<project_id>.secrets.env (never in the repo).",
|
|
2102
|
+
"down keeps the data; down --wipe deletes this project's database and Hatchet state (ask the human first)."
|
|
2103
|
+
],
|
|
2104
|
+
writes: "Docker containers and volumes, and ~/.gtm/stacks/<project_id>.env. No repo files.",
|
|
2105
|
+
examples: ["npx gtm stack up", "npx gtm stack status", "GTM_PG_PORT=5433 npx gtm stack up"]
|
|
1139
2106
|
}
|
|
1140
2107
|
};
|
|
1141
2108
|
function generalHelp(version) {
|
|
@@ -1184,12 +2151,16 @@ var { values, positionals } = parseArgs({
|
|
|
1184
2151
|
name: { type: "string" },
|
|
1185
2152
|
dir: { type: "string" },
|
|
1186
2153
|
slug: { type: "string" },
|
|
2154
|
+
pr: { type: "string" },
|
|
2155
|
+
apply: { type: "boolean" },
|
|
2156
|
+
"verify-github": { type: "boolean" },
|
|
1187
2157
|
by: { type: "string" },
|
|
1188
2158
|
notes: { type: "string" },
|
|
1189
2159
|
date: { type: "string" },
|
|
1190
2160
|
check: { type: "boolean" },
|
|
1191
2161
|
"dry-run": { type: "boolean" },
|
|
1192
2162
|
force: { type: "boolean" },
|
|
2163
|
+
wipe: { type: "boolean" },
|
|
1193
2164
|
help: { type: "boolean", short: "h" },
|
|
1194
2165
|
version: { type: "boolean", short: "v" }
|
|
1195
2166
|
}
|
|
@@ -1212,7 +2183,7 @@ ${generalHelp(GTM_VERSION)}`);
|
|
|
1212
2183
|
console.log(text);
|
|
1213
2184
|
return true;
|
|
1214
2185
|
}
|
|
1215
|
-
function run() {
|
|
2186
|
+
async function run() {
|
|
1216
2187
|
if (values.version) {
|
|
1217
2188
|
console.log(GTM_VERSION);
|
|
1218
2189
|
return true;
|
|
@@ -1224,11 +2195,17 @@ function run() {
|
|
|
1224
2195
|
case "new-campaign":
|
|
1225
2196
|
return runNewCampaign(root, args[0]);
|
|
1226
2197
|
case "validate":
|
|
1227
|
-
return runValidate(root, args[0]);
|
|
2198
|
+
return runValidate(root, args[0], { verifyGithub: values["verify-github"] });
|
|
1228
2199
|
case "status":
|
|
1229
2200
|
return runStatus(root);
|
|
2201
|
+
case "icp":
|
|
2202
|
+
return runIcp(root);
|
|
1230
2203
|
case "approve":
|
|
1231
|
-
return runApprove(root, args, { by: values.by, notes: values.notes, date: values.date });
|
|
2204
|
+
return runApprove(root, args, { by: values.by, notes: values.notes, date: values.date, pr: values.pr });
|
|
2205
|
+
case "track":
|
|
2206
|
+
return runTrack(root, args, { pr: values.pr });
|
|
2207
|
+
case "github":
|
|
2208
|
+
return runGithub(root, args, { apply: values.apply });
|
|
1232
2209
|
case "sync":
|
|
1233
2210
|
return runSync(root, { check: values.check });
|
|
1234
2211
|
case "migrate":
|
|
@@ -1236,7 +2213,9 @@ function run() {
|
|
|
1236
2213
|
case "upgrade":
|
|
1237
2214
|
return runUpgrade(root, { version: args[0], force: values.force });
|
|
1238
2215
|
case "job":
|
|
1239
|
-
return runJob(args
|
|
2216
|
+
return runJob(root, args);
|
|
2217
|
+
case "stack":
|
|
2218
|
+
return runStack(root, args, { wipe: values.wipe });
|
|
1240
2219
|
case void 0:
|
|
1241
2220
|
return help(void 0);
|
|
1242
2221
|
case "help":
|
|
@@ -1248,4 +2227,4 @@ ${generalHelp(GTM_VERSION)}`);
|
|
|
1248
2227
|
return false;
|
|
1249
2228
|
}
|
|
1250
2229
|
}
|
|
1251
|
-
process.exit(run() ? 0 : 1);
|
|
2230
|
+
process.exit(await run() ? 0 : 1);
|