@siloy/hubspot-provisioning 0.0.0-stage → 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/dist/engine/src/blueprint/associations.d.ts +17 -0
  4. package/dist/engine/src/blueprint/associations.js +22 -0
  5. package/dist/engine/src/blueprint/field-types.d.ts +14 -0
  6. package/dist/engine/src/blueprint/field-types.js +22 -0
  7. package/dist/engine/src/blueprint/formula.d.ts +25 -0
  8. package/dist/engine/src/blueprint/formula.js +106 -0
  9. package/dist/engine/src/blueprint/groups.d.ts +16 -0
  10. package/dist/engine/src/blueprint/groups.js +30 -0
  11. package/dist/engine/src/blueprint/index.d.ts +10 -0
  12. package/dist/engine/src/blueprint/index.js +11 -0
  13. package/dist/engine/src/blueprint/mapping-targets.d.ts +10 -0
  14. package/dist/engine/src/blueprint/mapping-targets.js +5 -0
  15. package/dist/engine/src/blueprint/object-names.d.ts +11 -0
  16. package/dist/engine/src/blueprint/object-names.js +22 -0
  17. package/dist/engine/src/blueprint/pipelines.d.ts +40 -0
  18. package/dist/engine/src/blueprint/pipelines.js +99 -0
  19. package/dist/engine/src/blueprint/schema.d.ts +422 -0
  20. package/dist/engine/src/blueprint/schema.js +163 -0
  21. package/dist/engine/src/blueprint/serialize.d.ts +22 -0
  22. package/dist/engine/src/blueprint/serialize.js +70 -0
  23. package/dist/engine/src/blueprint/validate.d.ts +35 -0
  24. package/dist/engine/src/blueprint/validate.js +385 -0
  25. package/dist/engine/src/errors.d.ts +40 -0
  26. package/dist/engine/src/errors.js +73 -0
  27. package/dist/provisioning/src/api/client.d.ts +36 -0
  28. package/dist/provisioning/src/api/client.js +186 -0
  29. package/dist/provisioning/src/api/commit.d.ts +13 -0
  30. package/dist/provisioning/src/api/commit.js +12 -0
  31. package/dist/provisioning/src/api/types.d.ts +98 -0
  32. package/dist/provisioning/src/api/types.js +5 -0
  33. package/dist/provisioning/src/cli/commands.d.ts +16 -0
  34. package/dist/provisioning/src/cli/commands.js +373 -0
  35. package/dist/provisioning/src/cli/hints.d.ts +4 -0
  36. package/dist/provisioning/src/cli/hints.js +21 -0
  37. package/dist/provisioning/src/cli/main.d.ts +2 -0
  38. package/dist/provisioning/src/cli/main.js +24 -0
  39. package/dist/provisioning/src/cli/render.d.ts +25 -0
  40. package/dist/provisioning/src/cli/render.js +149 -0
  41. package/dist/provisioning/src/index.d.ts +6 -0
  42. package/dist/provisioning/src/index.js +5 -0
  43. package/dist/provisioning/src/limits.d.ts +2 -0
  44. package/dist/provisioning/src/limits.js +2 -0
  45. package/dist/provisioning/src/targets.d.ts +16 -0
  46. package/dist/provisioning/src/targets.js +27 -0
  47. package/dist/provisioning/src/validate.d.ts +10 -0
  48. package/dist/provisioning/src/validate.js +15 -0
  49. package/dist/provisioning/src/version.d.ts +2 -0
  50. package/dist/provisioning/src/version.js +17 -0
  51. package/package.json +38 -4
  52. package/schema/blueprint.schema.json +420 -0
@@ -0,0 +1,373 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CiApiError, CiClient, CiTimeoutError } from '../api/client.js';
3
+ import { commitInfo } from '../api/commit.js';
4
+ import { MAX_BLUEPRINT_BYTES } from '../limits.js';
5
+ import { checkMappingTargets, mappingTargetsSchema } from '../targets.js';
6
+ import { validateBlueprintText } from '../validate.js';
7
+ import { CLI_VERSION } from '../version.js';
8
+ import { hintFor } from './hints.js';
9
+ import { blockedReason, renderComment, renderFailureComment, renderRunText } from './render.js';
10
+ const DEFAULT_TIMEOUT_MINUTES = 15;
11
+ const MAX_TIMEOUT_MINUTES = 360;
12
+ const APPLY_MAY_RUN = 'The apply may still be running; check the run in the provisioning app before re-running.';
13
+ const USAGE = `Usage: hubspot-provisioning <command> [options]
14
+
15
+ Commands:
16
+ validate [file] [--targets <json>]
17
+ Check the blueprint (default blueprint.yaml) offline.
18
+ plan [file] --mode preview|deploy [--targets <json>] [--comment-file <path>]
19
+ [--sha <sha>] [--ref <ref>] [--ci-run-url <url>] [--timeout <minutes>]
20
+ Plan the blueprint on the key's portal and print the result.
21
+ apply --plan-run <runId> [--timeout <minutes>]
22
+ Apply a deploy plan made by the same key.
23
+ deploy [file] [plan options except --mode]
24
+ Plan in deploy mode, then apply when there is something to apply.
25
+
26
+ Options:
27
+ --targets <json> Mapping targets: [{ "objectType": "contacts", "properties": ["..."] }]
28
+ --comment-file <path> Write the plan as a Markdown PR comment.
29
+ --timeout <minutes> Give up after this long (default ${DEFAULT_TIMEOUT_MINUTES}, at most ${MAX_TIMEOUT_MINUTES}).
30
+ --help, --version
31
+
32
+ Environment: PROVISIONING_URL and PROVISIONING_KEY (plan, apply, deploy).
33
+ Exit codes: 0 success, 1 the blueprint, plan or apply failed, 2 usage or configuration error.`;
34
+ /** Exit 2: a usage or configuration error. */
35
+ class UsageError extends Error {
36
+ }
37
+ /** Exit 1, after the lines were printed. `message` and `issues` go in the failure comment. */
38
+ class Failed extends Error {
39
+ issues;
40
+ constructor(message, issues = []) {
41
+ super(message);
42
+ this.issues = issues;
43
+ }
44
+ }
45
+ const PLAN_OPTIONS = {
46
+ targets: { type: 'string' },
47
+ 'comment-file': { type: 'string' },
48
+ sha: { type: 'string' },
49
+ ref: { type: 'string' },
50
+ 'ci-run-url': { type: 'string' },
51
+ timeout: { type: 'string' },
52
+ };
53
+ /** Runs one CLI command (#105). Returns the exit code (0, 1 or 2). */
54
+ export async function runCli(argv, io) {
55
+ // Belt and braces: server text is scrubbed when it comes in (withServer); this catches whatever else carries the key.
56
+ const key = io.env.PROVISIONING_KEY?.trim() ?? '';
57
+ const safe = { ...io, out: (line) => io.out(scrubbed(line, key)), err: (line) => io.err(scrubbed(line, key)) };
58
+ const [command, ...rest] = argv;
59
+ try {
60
+ if (command === '--version' || command === '-v') {
61
+ safe.out(CLI_VERSION);
62
+ return 0;
63
+ }
64
+ if (command === '--help' || command === '-h' || (isCommand(command) && (rest.includes('--help') || rest.includes('-h')))) {
65
+ safe.out(USAGE);
66
+ return 0;
67
+ }
68
+ switch (command) {
69
+ case 'validate': return await validate(rest, safe);
70
+ case 'plan': return await plan(rest, safe, null);
71
+ case 'deploy': return await plan(rest, safe, 'deploy');
72
+ case 'apply': return await apply(rest, safe);
73
+ default:
74
+ safe.err(command === undefined ? 'No command given.' : `Unknown command: ${command}`);
75
+ safe.err(USAGE);
76
+ return 2;
77
+ }
78
+ }
79
+ catch (error) {
80
+ if (error instanceof UsageError) {
81
+ safe.err(error.message);
82
+ safe.err('Run hubspot-provisioning --help for usage.');
83
+ return 2;
84
+ }
85
+ if (error instanceof Failed)
86
+ return 1;
87
+ throw error;
88
+ }
89
+ }
90
+ const isCommand = (name) => name === 'validate' || name === 'plan' || name === 'deploy' || name === 'apply';
91
+ async function validate(argv, io) {
92
+ const { values, positionals } = parse(argv, { targets: { type: 'string' } }, 1);
93
+ const targets = values.targets === undefined ? null : await readTargets(values.targets, io);
94
+ const file = positionals[0] ?? 'blueprint.yaml';
95
+ const blueprint = await readBlueprint(file, io);
96
+ io.out('Blueprint is valid.');
97
+ if (targets) {
98
+ const { assumed } = checkMappingTargets(blueprint, targets);
99
+ if (assumed.length > 0)
100
+ io.out(`Assumed to exist in HubSpot: ${assumed.map((t) => (t.property === null ? t.objectType : `${t.objectType}.${t.property}`)).join(', ')}`);
101
+ }
102
+ return 0;
103
+ }
104
+ /**
105
+ * `plan`, or `deploy` when `forced` is 'deploy'. Once the arguments parse, every failure writes a failure comment
106
+ * (with --comment-file), unless the plan's own comment was written already; the exit code stays the failure's. A
107
+ * deploy whose apply fails or times out replaces the plan's comment ("Ready to deploy.") with the failure comment.
108
+ */
109
+ async function plan(argv, io, forced) {
110
+ const options = forced ? PLAN_OPTIONS : { ...PLAN_OPTIONS, mode: { type: 'string' } };
111
+ const { values, positionals } = parse(argv, options, 1);
112
+ const given = forced ?? values.mode;
113
+ const key = io.env.PROVISIONING_KEY?.trim() ?? '';
114
+ const commentFile = values['comment-file'];
115
+ let commented = false;
116
+ /** Set once the deploy's apply starts: from then on a failure comment replaces the plan's. */
117
+ let applying = false;
118
+ const comment = async (render) => {
119
+ if (commentFile === undefined || (commented && !applying))
120
+ return;
121
+ commented = true;
122
+ await writeComment(commentFile, render(), io);
123
+ };
124
+ const onFailure = (message, hint, extra = {}) => comment(() => renderFailureComment(given === 'preview' || given === 'deploy' ? given : null, scrubbed(message, key), hint, { issues: scrubbed(extra.issues ?? [], key), runs: extra.runs ?? {} }));
125
+ try {
126
+ return await planSteps(values, positionals, io, forced, comment, onFailure, () => { applying = true; });
127
+ }
128
+ catch (error) {
129
+ if (error instanceof UsageError)
130
+ await onFailure(error.message, null);
131
+ else if (error instanceof Failed)
132
+ await onFailure(error.message, null, { issues: error.issues });
133
+ else
134
+ await onFailure(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`, null);
135
+ throw error;
136
+ }
137
+ }
138
+ async function planSteps(values, positionals, io, forced, comment, onFailure, startApplying) {
139
+ const mode = forced ?? modeOf(values.mode);
140
+ const minutes = timeoutOf(values.timeout);
141
+ const env = environment(io);
142
+ const targets = values.targets === undefined ? [] : await readTargets(values.targets, io);
143
+ const commit = commitInfo(io.env, { sha: values.sha, ref: values.ref, ciRunUrl: values['ci-run-url'] });
144
+ if ('error' in commit)
145
+ throw new UsageError(commit.error);
146
+ const file = positionals[0] ?? 'blueprint.yaml';
147
+ const yaml = await readText(file, io);
148
+ await readBlueprint(file, io, yaml);
149
+ const ids = {};
150
+ return withServer(io, minutes, ids, env, onFailure, async (client, remaining) => {
151
+ const created = await client.createPlan({ yaml, mode, commit, mappingTargets: targets });
152
+ ids.plan = created.runId;
153
+ io.out(`Planning ${mode} of ${file}${created.version === null ? '' : ` as version ${created.version}`}: run ${created.runId}`);
154
+ const run = await client.waitForRun(created.runId, remaining());
155
+ io.out('');
156
+ for (const line of renderRunText(run))
157
+ io.out(line);
158
+ await comment(() => renderComment(run, { mode, version: created.version }));
159
+ // Also a deploy plan that is not appliable for no stated reason: blockedReason says so, renderRunText printed it.
160
+ if (blockedReason(run) !== null)
161
+ return 1;
162
+ if (forced === null || run.refusal?.code === 'nothing_to_apply') {
163
+ if (forced === null && mode === 'deploy' && run.appliable)
164
+ io.out(`Next: hubspot-provisioning apply --plan-run ${run.id}`);
165
+ return 0;
166
+ }
167
+ if (!run.appliable)
168
+ return 1;
169
+ startApplying();
170
+ return applyRun(client, run.id, io, ids, remaining, async (failed) => {
171
+ const message = `The apply failed: ${failed.error?.message ?? `the run ${failed.status === 'failed' ? 'failed' : 'did not finish'}.`}`;
172
+ await onFailure(message, failed.error ? hintFor(failed.error.code) : null, { runs: { ...ids } });
173
+ });
174
+ });
175
+ }
176
+ async function apply(argv, io) {
177
+ const { values } = parse(argv, { 'plan-run': { type: 'string' }, timeout: { type: 'string' } }, 0);
178
+ const planRun = values['plan-run'];
179
+ if (!planRun)
180
+ throw new UsageError('Give the plan run to apply: --plan-run <runId>.');
181
+ const minutes = timeoutOf(values.timeout);
182
+ const env = environment(io);
183
+ const ids = { plan: planRun };
184
+ return withServer(io, minutes, ids, env, async () => { }, (client, remaining) => applyRun(client, planRun, io, ids, remaining));
185
+ }
186
+ async function applyRun(client, planRunId, io, ids, remaining, onFailed) {
187
+ let runId;
188
+ try {
189
+ ({ runId } = await client.createApply(planRunId));
190
+ }
191
+ catch (error) {
192
+ if (error instanceof CiApiError && error.code === 'nothing_to_apply') {
193
+ io.out('Nothing to apply: the portal already matches this plan.');
194
+ return 0;
195
+ }
196
+ throw error;
197
+ }
198
+ ids.apply = runId;
199
+ io.out('');
200
+ io.out(`Applying plan ${planRunId}: run ${runId}`);
201
+ const run = await client.waitForRun(runId, remaining());
202
+ io.out('');
203
+ for (const line of renderRunText(run))
204
+ io.out(line);
205
+ if (run.status === 'succeeded')
206
+ return 0;
207
+ await onFailed?.(run);
208
+ return 1;
209
+ }
210
+ /**
211
+ * Creates the client with the command's deadline, scrubs the key from every run view it returns, and turns server
212
+ * refusals and timeouts into exit 1, after `onFailure` (the plan's failure comment).
213
+ */
214
+ async function withServer(io, minutes, ids, env, onFailure, work) {
215
+ const deadline = new Date(Date.now() + minutes * 60_000);
216
+ const raw = (io.client ?? defaultClient)(env.url, env.key, { deadline });
217
+ const client = {
218
+ createPlan: (body) => raw.createPlan(body),
219
+ createApply: (planRunId) => raw.createApply(planRunId),
220
+ getRun: async (runId) => scrubbed(await raw.getRun(runId), env.key),
221
+ waitForRun: async (runId, timeoutMs) => scrubbed(await raw.waitForRun(runId, timeoutMs), env.key),
222
+ };
223
+ try {
224
+ return await work(client, () => Math.max(0, deadline.getTime() - Date.now()));
225
+ }
226
+ catch (error) {
227
+ if (error instanceof CiTimeoutError) {
228
+ const message = `Timed out after ${minutes} minute${minutes === 1 ? '' : 's'} waiting for the provisioning app.`;
229
+ io.err(message);
230
+ io.err(scrubbed(error.message, env.key));
231
+ printRunIds(io, ids);
232
+ await onFailure(message, null, { runs: { ...ids } });
233
+ return 1;
234
+ }
235
+ if (error instanceof CiApiError) {
236
+ const message = scrubbed(error.message, env.key);
237
+ const issues = issuesOf(scrubbed(error.details, env.key));
238
+ io.err(`Error (${error.code}): ${message}`);
239
+ for (const issue of issues)
240
+ io.err(` - ${issue}`);
241
+ const hint = hintFor(error.code);
242
+ if (hint)
243
+ io.err(`Hint: ${hint}`);
244
+ printRunIds(io, ids);
245
+ await onFailure(message, hint, { issues, runs: { ...ids } });
246
+ return 1;
247
+ }
248
+ throw error;
249
+ }
250
+ }
251
+ /** The runs a failed command got to, so a person can look them up; an apply that was waited on may still be going. */
252
+ function printRunIds(io, ids) {
253
+ if (ids.plan)
254
+ io.err(`Plan run: ${ids.plan}`);
255
+ if (ids.apply) {
256
+ io.err(`Apply run: ${ids.apply}`);
257
+ io.err(APPLY_MAY_RUN);
258
+ }
259
+ }
260
+ /** `value` with every occurrence of the key, in any string inside it, replaced by `[key]`. */
261
+ function scrubbed(value, key) {
262
+ if (!key)
263
+ return value;
264
+ if (typeof value === 'string')
265
+ return value.split(key).join('[key]');
266
+ if (Array.isArray(value))
267
+ return value.map((item) => scrubbed(item, key));
268
+ if (value !== null && typeof value === 'object')
269
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubbed(v, key)]));
270
+ return value;
271
+ }
272
+ const defaultClient = (baseUrl, key, options) => new CiClient({ baseUrl, key, deadline: options.deadline });
273
+ function issuesOf(details) {
274
+ const issues = details?.issues;
275
+ return Array.isArray(issues) ? issues.filter((i) => typeof i === 'string') : [];
276
+ }
277
+ function parse(argv, options, maxPositionals) {
278
+ let parsed;
279
+ try {
280
+ parsed = parseArgs({ args: argv, options, allowPositionals: true, strict: true });
281
+ }
282
+ catch (error) {
283
+ throw new UsageError(error.message);
284
+ }
285
+ if (parsed.positionals.length > maxPositionals)
286
+ throw new UsageError(`Unexpected argument: ${parsed.positionals[maxPositionals]}`);
287
+ return parsed;
288
+ }
289
+ function modeOf(mode) {
290
+ if (mode === 'preview' || mode === 'deploy')
291
+ return mode;
292
+ throw new UsageError(mode === undefined ? 'Give the mode: --mode preview or --mode deploy.' : `Unknown mode: ${mode}. Use preview or deploy.`);
293
+ }
294
+ function timeoutOf(text) {
295
+ if (text === undefined)
296
+ return DEFAULT_TIMEOUT_MINUTES;
297
+ const minutes = Number(text);
298
+ if (text.trim() === '' || !Number.isFinite(minutes) || minutes <= 0 || minutes > MAX_TIMEOUT_MINUTES) {
299
+ throw new UsageError(`--timeout takes a number of minutes above 0 and at most ${MAX_TIMEOUT_MINUTES}, not "${text}".`);
300
+ }
301
+ return minutes;
302
+ }
303
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
304
+ function environment(io) {
305
+ const url = io.env.PROVISIONING_URL?.trim();
306
+ if (!url)
307
+ throw new UsageError('PROVISIONING_URL is not set: give the provisioning app URL, for example https://provisioning.example.com.');
308
+ let parsed;
309
+ try {
310
+ parsed = new URL(url);
311
+ }
312
+ catch {
313
+ throw new UsageError('PROVISIONING_URL is not a valid URL.');
314
+ }
315
+ // The key goes along as a bearer token: never in clear text, except to this machine.
316
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && LOCAL_HOSTS.has(parsed.hostname))) {
317
+ throw new UsageError('PROVISIONING_URL must start with https://. Plain http:// is allowed only for localhost, 127.0.0.1 and [::1].');
318
+ }
319
+ const key = io.env.PROVISIONING_KEY?.trim();
320
+ if (!key)
321
+ throw new UsageError('PROVISIONING_KEY is not set: give the deploy key secret (hspk_...) from the repo\'s CI secrets.');
322
+ return { url, key };
323
+ }
324
+ async function readText(file, io) {
325
+ try {
326
+ return await io.readFile(file);
327
+ }
328
+ catch (error) {
329
+ throw new UsageError(`Could not read ${file}: ${error.message}`);
330
+ }
331
+ }
332
+ async function readTargets(file, io) {
333
+ const text = await readText(file, io);
334
+ let json;
335
+ try {
336
+ json = JSON.parse(text);
337
+ }
338
+ catch (error) {
339
+ throw new UsageError(`The targets file ${file} is not valid JSON: ${error.message}`);
340
+ }
341
+ const result = mappingTargetsSchema.safeParse(json);
342
+ if (!result.success) {
343
+ const issues = result.error.issues.map((i) => `${i.path.length > 0 ? `${i.path.join('.')}: ` : ''}${i.message}`);
344
+ throw new UsageError(`The targets file ${file} is not valid: ${issues.join('; ')}`);
345
+ }
346
+ return result.data;
347
+ }
348
+ /** Reads (unless given) and validates the blueprint; prints the issues and fails (exit 1) when it is too large or invalid. */
349
+ async function readBlueprint(file, io, text) {
350
+ const yaml = text ?? await readText(file, io);
351
+ if (Buffer.byteLength(yaml, 'utf8') > MAX_BLUEPRINT_BYTES) {
352
+ const message = 'The blueprint is larger than 5 MiB; the server refuses it.';
353
+ io.err(message);
354
+ throw new Failed(message);
355
+ }
356
+ const result = validateBlueprintText(yaml);
357
+ if (!result.ok) {
358
+ io.err(`${file} is not a valid blueprint:`);
359
+ for (const issue of result.issues)
360
+ io.err(` - ${issue}`);
361
+ throw new Failed(`${file} is not a valid blueprint.`, result.issues);
362
+ }
363
+ return result.blueprint;
364
+ }
365
+ /** Writes the comment; a failure is printed and otherwise ignored, so the command keeps its own exit code. */
366
+ async function writeComment(path, text, io) {
367
+ try {
368
+ await io.writeFile(path, text);
369
+ }
370
+ catch (error) {
371
+ io.err(`Could not write the comment file ${path}: ${error instanceof Error ? error.message : String(error)}`);
372
+ }
373
+ }
@@ -0,0 +1,4 @@
1
+ /** The codes with a hint; apps/web/test/server/ci/ci-contract.test.ts checks the server can send each (except the client's own). */
2
+ export declare const HINTED_CODES: readonly string[];
3
+ /** What to do about an error or refusal code, added after the server's message (#105). Null when the message says it all. */
4
+ export declare function hintFor(code: string): string | null;
@@ -0,0 +1,21 @@
1
+ const RECONNECT = 'A person must reconnect the portal in the provisioning app, then re-run this job.';
2
+ const REACH = 'Check PROVISIONING_URL and that the provisioning app is reachable.';
3
+ const HINTS = {
4
+ deploy_key_expired: 'Ask a workspace owner to reactivate the key on the blueprint page, then re-run this job. The secret stays the same.',
5
+ unauthenticated: 'The key is not valid (unknown, revoked or mistyped). Ask a workspace owner for a new key or a one-time link, and update the PROVISIONING_KEY secret.',
6
+ forbidden: "The key's portal is no longer in the workspace.",
7
+ deploy_key_revoked: 'The key was revoked; an apply may already have changed the portal. Plan again with a new key.',
8
+ reconnect_required: RECONNECT,
9
+ missing_scopes: RECONNECT,
10
+ cli_outdated: 'Upgrade: npm install --save-dev @siloy/hubspot-provisioning@latest, or raise the version your workflow runs with npx.',
11
+ rate_limited: "The key's rate limit is used up; wait 15 minutes.",
12
+ network: REACH,
13
+ http_error: REACH,
14
+ unexpected_response: 'The answer did not match this CLI. Upgrade @siloy/hubspot-provisioning, and check that PROVISIONING_URL is the provisioning app itself, not a proxy or sign-in page.',
15
+ };
16
+ /** The codes with a hint; apps/web/test/server/ci/ci-contract.test.ts checks the server can send each (except the client's own). */
17
+ export const HINTED_CODES = Object.keys(HINTS);
18
+ /** What to do about an error or refusal code, added after the server's message (#105). Null when the message says it all. */
19
+ export function hintFor(code) {
20
+ return Object.hasOwn(HINTS, code) ? HINTS[code] : null;
21
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { runCli } from './commands.js';
4
+ let code;
5
+ try {
6
+ code = await runCli(process.argv.slice(2), {
7
+ out: (line) => console.log(line),
8
+ err: (line) => console.error(line),
9
+ env: process.env,
10
+ readFile: (path) => readFile(path, 'utf8'),
11
+ writeFile: (path, text) => writeFile(path, text, 'utf8'),
12
+ });
13
+ }
14
+ catch (error) {
15
+ // A bug, not a refusal: print only the message, never the object (it may hold a request, and with it the key).
16
+ const key = process.env.PROVISIONING_KEY?.trim();
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ console.error(`Unexpected error: ${key ? message.split(key).join('[key]') : message}`);
19
+ code = 1;
20
+ }
21
+ // Piped stdout/stderr are asynchronous on macOS: flush them before exiting.
22
+ await new Promise((resolve) => process.stdout.write('', () => resolve()));
23
+ await new Promise((resolve) => process.stderr.write('', () => resolve()));
24
+ process.exit(code);
@@ -0,0 +1,25 @@
1
+ import type { CiMode, CiRunView } from '../api/types.ts';
2
+ /** First line of every PR comment, so a workflow can find and update its own comment. */
3
+ export declare const COMMENT_MARKER = "<!-- hubspot-provisioning -->";
4
+ /** The plan and apply runs a command created, as far as it got. */
5
+ export interface RunIds {
6
+ plan?: string;
7
+ apply?: string;
8
+ }
9
+ /** The console lines for a finished run (#105). Server text is printed as is. */
10
+ export declare function renderRunText(run: CiRunView): string[];
11
+ /** The Markdown PR comment for a finished plan (#105). All server text goes in escaped. */
12
+ export declare function renderComment(run: CiRunView, context: {
13
+ mode: CiMode;
14
+ version: number | null;
15
+ }): string;
16
+ /**
17
+ * The PR comment when no finished plan came back: the blueprint failed offline, the configuration is wrong, the server
18
+ * refused, or the time ran out. `mode` is null when it was not given or not valid. At most 10 issues are listed.
19
+ */
20
+ export declare function renderFailureComment(mode: CiMode | null, message: string, hint: string | null, extra?: {
21
+ issues?: string[];
22
+ runs?: RunIds;
23
+ }): string;
24
+ /** Why a finished plan cannot be deployed, or null. Mirrors the plan command's exit 1. */
25
+ export declare function blockedReason(run: CiRunView): string | null;
@@ -0,0 +1,149 @@
1
+ import { hintFor } from './hints.js';
2
+ /** First line of every PR comment, so a workflow can find and update its own comment. */
3
+ export const COMMENT_MARKER = '<!-- hubspot-provisioning -->';
4
+ const MAX_COMMENT_ISSUES = 10;
5
+ const BASELINE_INVALID = "This key's last deploy no longer passes today's blueprint rules, so every update blocks until a deploy with nothing to apply.";
6
+ const ACTIONS = {
7
+ plan_again: 'plan again: re-run this job',
8
+ reconnect: 'a person reconnects the portal in the provisioning app',
9
+ };
10
+ const REASONS = {
11
+ changed_in_hubspot: 'changed in HubSpot since the last deploy',
12
+ no_baseline: 'no earlier deploy by this key to compare with',
13
+ };
14
+ /** The console lines for a finished run (#105). Server text is printed as is. */
15
+ export function renderRunText(run) {
16
+ const lines = [`${title(run)}: ${run.status}`];
17
+ if (run.summary)
18
+ lines.push(`Changes: ${counts(run.summary) || 'none'}`);
19
+ section(lines, 'Actions:', run.actions.map((a) => `${a.type} ${a.target}: ${a.summary}`));
20
+ section(lines, 'Conflicts:', run.conflicts.map((c) => `${c.target} ${c.field}: ${c.remediation}`));
21
+ section(lines, 'Blocking updates (hand edits in HubSpot the apply would overwrite):', run.blocking.map(blockingText));
22
+ section(lines, 'Missing mapping targets (in neither the blueprint nor HubSpot):', run.missingTargets.map(targetName));
23
+ section(lines, 'Warnings:', run.warnings);
24
+ if (run.baselineInvalid)
25
+ lines.push(BASELINE_INVALID);
26
+ if (run.refusal) {
27
+ lines.push(run.refusal.message);
28
+ pushHint(lines, run.refusal.code);
29
+ }
30
+ const unexplained = unexplainedReason(run);
31
+ if (unexplained)
32
+ lines.push(unexplained);
33
+ if (run.error) {
34
+ lines.push(`Error (${run.error.code}): ${run.error.message}`);
35
+ if (run.error.action)
36
+ lines.push(`Action: ${run.error.action} (${ACTIONS[run.error.action] ?? run.error.action})`);
37
+ if (run.error.runId)
38
+ lines.push(`Other run: ${run.error.runId}`);
39
+ pushHint(lines, run.error.code);
40
+ }
41
+ return lines;
42
+ }
43
+ /** The Markdown PR comment for a finished plan (#105). All server text goes in escaped. */
44
+ export function renderComment(run, context) {
45
+ const parts = [COMMENT_MARKER, `### Provisioning ${context.mode} plan`, ''];
46
+ const about = context.version === null ? `Run ${md(run.id)}.` : `Blueprint version ${context.version}, run ${md(run.id)}.`;
47
+ parts.push(about, '', run.summary ? `**Summary:** ${md(counts(run.summary)) || 'no changes.'}` : '**Summary:** unknown, the plan did not finish.', '');
48
+ mdSection(parts, 'Changes', run.actions.map((a) => `**${md(a.type)}** ${md(a.target)}: ${md(a.summary)}`));
49
+ mdSection(parts, 'Conflicts', run.conflicts.map((c) => `${md(c.target)} ${md(c.field)}: ${md(c.remediation)}`));
50
+ mdSection(parts, 'Blocking updates (hand edits in HubSpot)', run.blocking.map((b) => md(blockingText(b))));
51
+ mdSection(parts, 'Missing mapping targets', run.missingTargets.map((t) => md(targetName(t))));
52
+ mdSection(parts, 'Warnings', run.warnings.map(md));
53
+ if (run.baselineInvalid)
54
+ parts.push(md(BASELINE_INVALID), '');
55
+ parts.push(verdict(run));
56
+ return `${parts.join('\n')}\n`;
57
+ }
58
+ /**
59
+ * The PR comment when no finished plan came back: the blueprint failed offline, the configuration is wrong, the server
60
+ * refused, or the time ran out. `mode` is null when it was not given or not valid. At most 10 issues are listed.
61
+ */
62
+ export function renderFailureComment(mode, message, hint, extra = {}) {
63
+ const parts = [COMMENT_MARKER, `### Provisioning ${mode === null ? '' : `${mode} `}plan`, '', `**Not deployable:** ${md(message)}`];
64
+ const issues = extra.issues ?? [];
65
+ if (issues.length > 0) {
66
+ parts.push('', ...issues.slice(0, MAX_COMMENT_ISSUES).map((issue) => `- ${md(issue)}`));
67
+ if (issues.length > MAX_COMMENT_ISSUES)
68
+ parts.push(`- …and ${issues.length - MAX_COMMENT_ISSUES} more`);
69
+ }
70
+ if (hint)
71
+ parts.push('', `Hint: ${md(hint)}`);
72
+ const runs = extra.runs ?? {};
73
+ if (runs.plan || runs.apply) {
74
+ parts.push('');
75
+ if (runs.plan)
76
+ parts.push(`Plan run: ${md(runs.plan)}`);
77
+ if (runs.apply)
78
+ parts.push(`Apply run: ${md(runs.apply)}`, '', md('The apply may still be running; check the run in the provisioning app before re-running.'));
79
+ }
80
+ return `${parts.join('\n')}\n`;
81
+ }
82
+ /** The same rule as the plan command's exit code: blocked is exit 1, everything else exit 0. */
83
+ function verdict(run) {
84
+ const reason = blockedReason(run);
85
+ if (reason !== null)
86
+ return `**Not deployable:** ${md(reason)}`;
87
+ if (run.refusal?.code === 'nothing_to_apply')
88
+ return '**Nothing to change.**';
89
+ return '**Ready to deploy.**';
90
+ }
91
+ /** Why a finished plan cannot be deployed, or null. Mirrors the plan command's exit 1. */
92
+ export function blockedReason(run) {
93
+ if (run.status !== 'succeeded')
94
+ return run.error?.message ?? `the run ${run.status === 'failed' ? 'failed' : 'did not finish'}.`;
95
+ if (run.refusal && run.refusal.code !== 'nothing_to_apply')
96
+ return run.refusal.message;
97
+ if (run.conflicts.length > 0)
98
+ return `${plural(run.conflicts.length, 'conflict')} with the portal.`;
99
+ if (run.blocking.length > 0)
100
+ return `${plural(run.blocking.length, 'update')} would overwrite hand edits in HubSpot.`;
101
+ if (run.missingTargets.length > 0)
102
+ return `${plural(run.missingTargets.length, 'mapping target')} in neither the blueprint nor HubSpot.`;
103
+ return unexplainedReason(run);
104
+ }
105
+ /** A succeeded deploy plan the server says cannot be applied, without saying why (no refusal): blocked all the same. */
106
+ function unexplainedReason(run) {
107
+ if (run.kind !== 'plan' || run.mode !== 'deploy' || run.status !== 'succeeded' || run.appliable || run.refusal !== null)
108
+ return null;
109
+ if (run.conflicts.length > 0 || run.blocking.length > 0 || run.missingTargets.length > 0)
110
+ return null;
111
+ return `The plan cannot be applied, and the server gave no reason. Plan again; if it persists, report it with run ${run.id}.`;
112
+ }
113
+ function title(run) {
114
+ const name = run.kind === 'apply' ? 'Apply' : `${run.mode === 'deploy' ? 'Deploy' : 'Preview'} plan`;
115
+ return `${name} ${run.id}${run.version === null ? '' : ` (version ${run.version})`}`;
116
+ }
117
+ const counts = (summary) => Object.entries(summary).map(([key, n]) => `${n} ${key}`).join(', ');
118
+ const targetName = (t) => (t.property === null ? t.objectType : `${t.objectType}.${t.property}`);
119
+ const blockingText = (b) => `${b.target} ${b.field}: HubSpot has ${value(b.hubspot)}, the repo has ${value(b.repo)} (${REASONS[b.reason] ?? b.reason})`;
120
+ function value(v) {
121
+ if (typeof v === 'string')
122
+ return `"${v}"`;
123
+ if (v === undefined || v === null)
124
+ return 'nothing';
125
+ return JSON.stringify(v);
126
+ }
127
+ const plural = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
128
+ function section(lines, heading, items) {
129
+ if (items.length === 0)
130
+ return;
131
+ lines.push(heading, ...items.map((item) => ` ${item}`));
132
+ }
133
+ function mdSection(parts, heading, items) {
134
+ if (items.length === 0)
135
+ return;
136
+ parts.push(`#### ${heading}`, '', ...items.map((item) => `- ${item}`), '');
137
+ }
138
+ function pushHint(lines, code) {
139
+ const hint = hintFor(code);
140
+ if (hint)
141
+ lines.push(`Hint: ${hint}`);
142
+ }
143
+ /**
144
+ * Server text as plain Markdown: on one line; every character that could format, link, embed HTML or start math
145
+ * escaped; and a zero-width non-joiner after each `@` and `#`, so GitHub makes no mention or issue reference of it.
146
+ */
147
+ function md(text) {
148
+ return text.replace(/\s*[\r\n]+\s*/g, ' ').replace(/[\\`*_{}[\]()<>#+!|~&@$-]/g, '\\$&').replace(/[@#]/g, '$&\u200C');
149
+ }
@@ -0,0 +1,6 @@
1
+ export { BLUEPRINT_FORMAT, type Blueprint } from '../../engine/src/blueprint/index.ts';
2
+ export { validateBlueprintText, type ValidationResult } from './validate.ts';
3
+ export { checkMappingTargets, mappingTargetsSchema, type AssumedTarget, type MappingTarget } from './targets.ts';
4
+ export { CLI_VERSION } from './version.ts';
5
+ export { CiApiError, CiClient, CiTimeoutError, type ClientOptions } from './api/client.ts';
6
+ export type { ApiErrorBody, BlockingUpdate, ClassifiedUpdate, CiMode, CiRunView, CreateApplyResponse, CreatePlanRequest, CreatePlanResponse, KnownErrorCode, RunStatus, } from './api/types.ts';
@@ -0,0 +1,5 @@
1
+ export { BLUEPRINT_FORMAT } from '../../engine/src/blueprint/index.js';
2
+ export { validateBlueprintText } from './validate.js';
3
+ export { checkMappingTargets, mappingTargetsSchema } from './targets.js';
4
+ export { CLI_VERSION } from './version.js';
5
+ export { CiApiError, CiClient, CiTimeoutError } from './api/client.js';
@@ -0,0 +1,2 @@
1
+ /** The server refuses YAML over this many UTF-8 bytes (413): MAX_BLUEPRINT_BYTES in apps/web/src/server/blueprints/limits.ts. */
2
+ export declare const MAX_BLUEPRINT_BYTES: number;
@@ -0,0 +1,2 @@
1
+ /** The server refuses YAML over this many UTF-8 bytes (413): MAX_BLUEPRINT_BYTES in apps/web/src/server/blueprints/limits.ts. */
2
+ export const MAX_BLUEPRINT_BYTES = 5 * 1024 * 1024;
@@ -0,0 +1,16 @@
1
+ import type { Blueprint, MappingTarget } from '../../engine/src/blueprint/index.ts';
2
+ export { mappingTargetsSchema, type MappingTarget } from '../../engine/src/blueprint/index.ts';
3
+ /** A mapping target the blueprint does not define, so it must already exist in HubSpot. */
4
+ export interface AssumedTarget {
5
+ objectType: string;
6
+ /** `null`: the whole object is not in the blueprint. */
7
+ property: string | null;
8
+ }
9
+ /**
10
+ * The offline part of the mapping-target check (#105): which targets the blueprint defines, and which are assumed to
11
+ * exist in HubSpot. `assumed` is sorted by object type, then property, `null` first; a missing object is listed once.
12
+ */
13
+ export declare function checkMappingTargets(blueprint: Blueprint, targets: MappingTarget[]): {
14
+ defined: MappingTarget[];
15
+ assumed: AssumedTarget[];
16
+ };