@siloy/hubspot-provisioning 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +203 -2
- package/dist/engine/src/blueprint/associations.d.ts +17 -0
- package/dist/engine/src/blueprint/associations.js +22 -0
- package/dist/engine/src/blueprint/field-types.d.ts +14 -0
- package/dist/engine/src/blueprint/field-types.js +22 -0
- package/dist/engine/src/blueprint/formula.d.ts +25 -0
- package/dist/engine/src/blueprint/formula.js +106 -0
- package/dist/engine/src/blueprint/groups.d.ts +16 -0
- package/dist/engine/src/blueprint/groups.js +30 -0
- package/dist/engine/src/blueprint/index.d.ts +10 -0
- package/dist/engine/src/blueprint/index.js +11 -0
- package/dist/engine/src/blueprint/mapping-targets.d.ts +10 -0
- package/dist/engine/src/blueprint/mapping-targets.js +5 -0
- package/dist/engine/src/blueprint/object-names.d.ts +11 -0
- package/dist/engine/src/blueprint/object-names.js +22 -0
- package/dist/engine/src/blueprint/pipelines.d.ts +40 -0
- package/dist/engine/src/blueprint/pipelines.js +99 -0
- package/dist/engine/src/blueprint/schema.d.ts +422 -0
- package/dist/engine/src/blueprint/schema.js +163 -0
- package/dist/engine/src/blueprint/serialize.d.ts +22 -0
- package/dist/engine/src/blueprint/serialize.js +70 -0
- package/dist/engine/src/blueprint/validate.d.ts +35 -0
- package/dist/engine/src/blueprint/validate.js +385 -0
- package/dist/engine/src/errors.d.ts +40 -0
- package/dist/engine/src/errors.js +73 -0
- package/dist/provisioning/src/api/client.d.ts +36 -0
- package/dist/provisioning/src/api/client.js +186 -0
- package/dist/provisioning/src/api/commit.d.ts +13 -0
- package/dist/provisioning/src/api/commit.js +12 -0
- package/dist/provisioning/src/api/types.d.ts +98 -0
- package/dist/provisioning/src/api/types.js +5 -0
- package/dist/provisioning/src/cli/commands.d.ts +16 -0
- package/dist/provisioning/src/cli/commands.js +373 -0
- package/dist/provisioning/src/cli/hints.d.ts +4 -0
- package/dist/provisioning/src/cli/hints.js +21 -0
- package/dist/provisioning/src/cli/main.d.ts +2 -0
- package/dist/provisioning/src/cli/main.js +24 -0
- package/dist/provisioning/src/cli/render.d.ts +25 -0
- package/dist/provisioning/src/cli/render.js +149 -0
- package/dist/provisioning/src/index.d.ts +6 -0
- package/dist/provisioning/src/index.js +5 -0
- package/dist/provisioning/src/limits.d.ts +2 -0
- package/dist/provisioning/src/limits.js +2 -0
- package/dist/provisioning/src/targets.d.ts +16 -0
- package/dist/provisioning/src/targets.js +27 -0
- package/dist/provisioning/src/validate.d.ts +10 -0
- package/dist/provisioning/src/validate.js +15 -0
- package/dist/provisioning/src/version.d.ts +2 -0
- package/dist/provisioning/src/version.js +17 -0
- package/package.json +38 -4
- package/schema/blueprint.schema.json +420 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { CLI_VERSION } from '../version.js';
|
|
2
|
+
const POLL_MS = 5000;
|
|
3
|
+
const WINDOW_MS = 15 * 60_000;
|
|
4
|
+
/** A request (and reading its answer) without a client deadline is aborted after this long. */
|
|
5
|
+
const MAX_REQUEST_MS = 60_000;
|
|
6
|
+
const UNEXPECTED = 'The server answered in an unexpected format. Upgrade @siloy/hubspot-provisioning, or check PROVISIONING_URL.';
|
|
7
|
+
const STATUSES = new Set(['queued', 'running', 'succeeded', 'failed']);
|
|
8
|
+
/** The server refused, or answered something unreadable. `status` 0 is a network failure. `message` is the server's text. */
|
|
9
|
+
export class CiApiError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
details;
|
|
13
|
+
constructor(status, code, message, details) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'CiApiError';
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.code = code;
|
|
18
|
+
if (details !== undefined)
|
|
19
|
+
this.details = details;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class CiTimeoutError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'CiTimeoutError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Client for the three CI endpoints (#105). The key lives in a private field and is scrubbed from everything thrown. */
|
|
29
|
+
export class CiClient {
|
|
30
|
+
#baseUrl;
|
|
31
|
+
#key;
|
|
32
|
+
#fetch;
|
|
33
|
+
#sleep;
|
|
34
|
+
#now;
|
|
35
|
+
#deadline;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.#baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
38
|
+
this.#key = options.key;
|
|
39
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
40
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
41
|
+
this.#now = options.now ?? (() => new Date());
|
|
42
|
+
this.#deadline = options.deadline?.getTime();
|
|
43
|
+
}
|
|
44
|
+
createPlan(body) {
|
|
45
|
+
return this.#request('POST', '/api/ci/plans', isCreated, body);
|
|
46
|
+
}
|
|
47
|
+
getRun(runId) {
|
|
48
|
+
return this.#request('GET', `/api/ci/runs/${encodeURIComponent(runId)}`, isRunView);
|
|
49
|
+
}
|
|
50
|
+
createApply(planRunId) {
|
|
51
|
+
return this.#request('POST', '/api/ci/applies', isCreated, { planRunId });
|
|
52
|
+
}
|
|
53
|
+
/** Polls getRun every 5 s until status is succeeded/failed; throws CiTimeoutError after `timeoutMs`. */
|
|
54
|
+
async waitForRun(runId, timeoutMs) {
|
|
55
|
+
const own = this.#now().getTime() + timeoutMs;
|
|
56
|
+
const deadline = this.#deadline === undefined ? own : Math.min(own, this.#deadline);
|
|
57
|
+
for (;;) {
|
|
58
|
+
const run = await this.#request('GET', `/api/ci/runs/${encodeURIComponent(runId)}`, isRunView, undefined, deadline);
|
|
59
|
+
if (run.status === 'succeeded' || run.status === 'failed')
|
|
60
|
+
return run;
|
|
61
|
+
if (this.#now().getTime() + POLL_MS > deadline)
|
|
62
|
+
throw new CiTimeoutError(`Timed out waiting for run ${runId} to finish.`);
|
|
63
|
+
await this.#sleep(POLL_MS);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async #request(method, path, valid, body, ownDeadline) {
|
|
67
|
+
const deadline = ownDeadline ?? this.#deadline;
|
|
68
|
+
for (;;) {
|
|
69
|
+
const { response, text } = await this.#exchange(method, path, body, deadline);
|
|
70
|
+
if (response.ok) {
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(text);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new CiApiError(response.status, 'unexpected_response', UNEXPECTED);
|
|
77
|
+
}
|
|
78
|
+
if (!valid(parsed))
|
|
79
|
+
throw new CiApiError(response.status, 'unexpected_response', UNEXPECTED);
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
82
|
+
const error = this.#errorOf(response.status, text);
|
|
83
|
+
if (error.status !== 429 || error.code !== 'rate_limited')
|
|
84
|
+
throw error;
|
|
85
|
+
const now = this.#now().getTime();
|
|
86
|
+
const wait = (Math.floor(now / WINDOW_MS) + 1) * WINDOW_MS + 1000 - now;
|
|
87
|
+
if (deadline !== undefined && now + wait > deadline)
|
|
88
|
+
throw new CiTimeoutError('Rate limited, and the next 15-minute window starts after the timeout.');
|
|
89
|
+
await this.#sleep(wait);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* One request and its whole answer, under one abort signal: the time left to `deadline`, else 60 s. An abort at the
|
|
94
|
+
* deadline is CiTimeoutError; one at the 60 s cap is a `network` error.
|
|
95
|
+
*/
|
|
96
|
+
async #exchange(method, path, body, deadline) {
|
|
97
|
+
const ms = deadline === undefined ? MAX_REQUEST_MS : deadline - this.#now().getTime();
|
|
98
|
+
if (ms <= 0)
|
|
99
|
+
throw new CiTimeoutError('The timeout ran out before the provisioning app answered.');
|
|
100
|
+
const signal = AbortSignal.timeout(ms);
|
|
101
|
+
const aborted = () => deadline === undefined
|
|
102
|
+
? new CiApiError(0, 'network', `Could not reach ${this.#baseUrl}: request timed out after ${Math.round(ms / 1000)} s`)
|
|
103
|
+
: new CiTimeoutError('The timeout ran out before the provisioning app answered.');
|
|
104
|
+
const response = await this.#send(method, path, body, signal, aborted);
|
|
105
|
+
try {
|
|
106
|
+
return { response, text: await untilAborted(response.text(), signal) };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
if (signal.aborted)
|
|
110
|
+
throw aborted();
|
|
111
|
+
throw new CiApiError(response.status, 'network', `Could not read the answer from ${this.#baseUrl}.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async #send(method, path, body, signal, aborted) {
|
|
115
|
+
try {
|
|
116
|
+
return await untilAborted(this.#fetch(`${this.#baseUrl}${path}`, {
|
|
117
|
+
method,
|
|
118
|
+
redirect: 'error',
|
|
119
|
+
signal,
|
|
120
|
+
headers: {
|
|
121
|
+
authorization: `Bearer ${this.#key}`,
|
|
122
|
+
'x-provisioning-cli-version': CLI_VERSION,
|
|
123
|
+
'content-type': 'application/json',
|
|
124
|
+
accept: 'application/json',
|
|
125
|
+
},
|
|
126
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
127
|
+
}), signal);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (signal.aborted)
|
|
131
|
+
throw aborted();
|
|
132
|
+
// Only text, never the cause object: it may hold the request, and with it the key.
|
|
133
|
+
throw new CiApiError(0, 'network', this.#scrub(`Could not reach ${this.#baseUrl}: ${reasonOf(error)}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
#errorOf(status, text) {
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(text);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return unreadable(status);
|
|
143
|
+
}
|
|
144
|
+
const body = parsed?.error;
|
|
145
|
+
if (!body || typeof body.code !== 'string' || typeof body.message !== 'string')
|
|
146
|
+
return unreadable(status);
|
|
147
|
+
return new CiApiError(status, body.code, this.#scrub(body.message), body.details === undefined ? undefined : JSON.parse(this.#scrub(JSON.stringify(body.details))));
|
|
148
|
+
}
|
|
149
|
+
#scrub(text) {
|
|
150
|
+
return this.#key ? text.split(this.#key).join('[key]') : text;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** `promise`, or a rejection as soon as `signal` aborts: also for a fetch that ignores its signal. */
|
|
154
|
+
function untilAborted(promise, signal) {
|
|
155
|
+
if (signal.aborted)
|
|
156
|
+
return Promise.reject(signal.reason);
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const onAbort = () => reject(signal.reason);
|
|
159
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
160
|
+
promise.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
164
|
+
/** The minimal shape of a 201 from POST /api/ci/plans or /api/ci/applies. */
|
|
165
|
+
function isCreated(body) {
|
|
166
|
+
return isObject(body) && typeof body.runId === 'string';
|
|
167
|
+
}
|
|
168
|
+
/** The minimal shape of a CiRunView: what the commands read without checking. */
|
|
169
|
+
function isRunView(body) {
|
|
170
|
+
return isObject(body) && typeof body.id === 'string' && STATUSES.has(body.status)
|
|
171
|
+
&& ['actions', 'conflicts', 'blocking', 'missingTargets'].every((name) => Array.isArray(body[name]));
|
|
172
|
+
}
|
|
173
|
+
function unreadable(status) {
|
|
174
|
+
return new CiApiError(status, 'http_error', `The server answered HTTP ${status} without a readable error.`);
|
|
175
|
+
}
|
|
176
|
+
/** The first non-empty of cause message/code, its first inner error's message/code, the error's own message. */
|
|
177
|
+
function reasonOf(error) {
|
|
178
|
+
const outer = (error ?? {});
|
|
179
|
+
const cause = (outer.cause ?? {});
|
|
180
|
+
const inner = (Array.isArray(cause.errors) ? cause.errors[0] ?? {} : {});
|
|
181
|
+
for (const value of [cause.message, cause.code, inner.message, inner.code, outer.message]) {
|
|
182
|
+
if (typeof value === 'string' && value !== '')
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
return 'unknown error';
|
|
186
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface CommitInfo {
|
|
2
|
+
sha: string;
|
|
3
|
+
ref: string;
|
|
4
|
+
ciRunUrl: string | null;
|
|
5
|
+
}
|
|
6
|
+
/** From flags, else GitHub Actions env. ciRunUrl = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` when all three are set, else null. */
|
|
7
|
+
export declare function commitInfo(env: Record<string, string | undefined>, flags: {
|
|
8
|
+
sha?: string;
|
|
9
|
+
ref?: string;
|
|
10
|
+
ciRunUrl?: string;
|
|
11
|
+
}): CommitInfo | {
|
|
12
|
+
error: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** From flags, else GitHub Actions env. ciRunUrl = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` when all three are set, else null. */
|
|
2
|
+
export function commitInfo(env, flags) {
|
|
3
|
+
const sha = flags.sha || env.GITHUB_SHA;
|
|
4
|
+
if (!sha)
|
|
5
|
+
return { error: 'No commit sha: pass --sha or run in GitHub Actions (GITHUB_SHA).' };
|
|
6
|
+
const ref = flags.ref || env.GITHUB_REF;
|
|
7
|
+
if (!ref)
|
|
8
|
+
return { error: 'No commit ref: pass --ref or run in GitHub Actions (GITHUB_REF).' };
|
|
9
|
+
const { GITHUB_SERVER_URL: server, GITHUB_REPOSITORY: repository, GITHUB_RUN_ID: runId } = env;
|
|
10
|
+
const fromEnv = server && repository && runId ? `${server}/${repository}/actions/runs/${runId}` : null;
|
|
11
|
+
return { sha, ref, ciRunUrl: flags.ciRunUrl || fromEnv };
|
|
12
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request and response shapes of the CI API. Mirrors docs/ci-api.md; apps/web/test/server/ci/ci-contract.test.ts checks
|
|
3
|
+
* the server's types against these at typecheck time.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* An error, refusal or run error code. The listed ones are those docs/ci-api.md documents, plus the client's own
|
|
7
|
+
* `network`, `http_error` and `unexpected_response`; the server may add codes, so any string is accepted.
|
|
8
|
+
*/
|
|
9
|
+
export type KnownErrorCode = 'invalid' | 'cli_outdated' | 'unauthenticated' | 'forbidden' | 'deploy_key_expired' | 'not_found' | 'preview_plan' | 'plan_unclassified' | 'blocking_updates' | 'mapping_target_missing' | 'nothing_to_apply' | 'reconnect_required' | 'apply_locked' | 'missing_scopes' | 'conflict' | 'too_large' | 'rate_limited' | 'internal' | 'environment' | 'hubspot' | 'plan_changed' | 'apply_failed' | 'deploy_key_revoked' | 'portal_moved' | 'dispatch_failed' | 'timed_out' | 'stopped' | 'never_started' | 'input' | 'network' | 'http_error' | 'unexpected_response' | (string & {});
|
|
10
|
+
export type CiMode = 'preview' | 'deploy';
|
|
11
|
+
export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
|
12
|
+
export interface CreatePlanRequest {
|
|
13
|
+
yaml: string;
|
|
14
|
+
mode: CiMode;
|
|
15
|
+
commit: {
|
|
16
|
+
sha: string;
|
|
17
|
+
ref: string;
|
|
18
|
+
ciRunUrl: string | null;
|
|
19
|
+
};
|
|
20
|
+
/** Defaults to `[]` on the server: no mapping check. */
|
|
21
|
+
mappingTargets?: {
|
|
22
|
+
objectType: string;
|
|
23
|
+
properties: string[];
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
export interface CreatePlanResponse {
|
|
27
|
+
runId: string;
|
|
28
|
+
versionId: string;
|
|
29
|
+
/** Null for a preview, unless it reused the latest numbered version. */
|
|
30
|
+
version: number | null;
|
|
31
|
+
reused: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface CreateApplyResponse {
|
|
34
|
+
runId: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ClassifiedUpdate {
|
|
37
|
+
kind: string;
|
|
38
|
+
target: string;
|
|
39
|
+
field: string;
|
|
40
|
+
repo: unknown;
|
|
41
|
+
hubspot: unknown;
|
|
42
|
+
baseline?: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface BlockingUpdate extends ClassifiedUpdate {
|
|
45
|
+
reason: 'no_baseline' | 'changed_in_hubspot';
|
|
46
|
+
}
|
|
47
|
+
export interface CiRunView {
|
|
48
|
+
id: string;
|
|
49
|
+
kind: 'plan' | 'apply';
|
|
50
|
+
status: RunStatus;
|
|
51
|
+
mode: CiMode;
|
|
52
|
+
versionId: string;
|
|
53
|
+
version: number | null;
|
|
54
|
+
/** `"{kind}:{type}"` to count, mutating actions only. Null until the plan finished. */
|
|
55
|
+
summary: Record<string, number> | null;
|
|
56
|
+
actions: {
|
|
57
|
+
kind: string;
|
|
58
|
+
type: string;
|
|
59
|
+
target: string;
|
|
60
|
+
summary: string;
|
|
61
|
+
}[];
|
|
62
|
+
conflicts: {
|
|
63
|
+
target: string;
|
|
64
|
+
field: string;
|
|
65
|
+
blueprint: unknown;
|
|
66
|
+
portal: unknown;
|
|
67
|
+
remediation: string;
|
|
68
|
+
}[];
|
|
69
|
+
warnings: string[];
|
|
70
|
+
blocking: BlockingUpdate[];
|
|
71
|
+
allowed: ClassifiedUpdate[];
|
|
72
|
+
/** `property: null` means the whole object. */
|
|
73
|
+
missingTargets: {
|
|
74
|
+
objectType: string;
|
|
75
|
+
property: string | null;
|
|
76
|
+
}[];
|
|
77
|
+
baselineInvalid: boolean;
|
|
78
|
+
appliable: boolean;
|
|
79
|
+
refusal: {
|
|
80
|
+
code: KnownErrorCode;
|
|
81
|
+
message: string;
|
|
82
|
+
} | null;
|
|
83
|
+
applyRunId: string | null;
|
|
84
|
+
error: {
|
|
85
|
+
code: KnownErrorCode;
|
|
86
|
+
message: string;
|
|
87
|
+
action?: 'plan_again' | 'reconnect';
|
|
88
|
+
runId?: string;
|
|
89
|
+
} | null;
|
|
90
|
+
finishedAt: string | null;
|
|
91
|
+
}
|
|
92
|
+
export interface ApiErrorBody {
|
|
93
|
+
error: {
|
|
94
|
+
code: KnownErrorCode;
|
|
95
|
+
message: string;
|
|
96
|
+
details?: unknown;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CiClient } from '../api/client.ts';
|
|
2
|
+
/** The CI calls the commands make; CiClient implements it. */
|
|
3
|
+
export type CiApi = Pick<CiClient, 'createPlan' | 'getRun' | 'createApply' | 'waitForRun'>;
|
|
4
|
+
export interface CliIo {
|
|
5
|
+
out: (line: string) => void;
|
|
6
|
+
err: (line: string) => void;
|
|
7
|
+
env: Record<string, string | undefined>;
|
|
8
|
+
readFile: (path: string) => Promise<string>;
|
|
9
|
+
writeFile: (path: string, text: string) => Promise<void>;
|
|
10
|
+
/** Defaults to a CiClient. `deadline` is when the command's --timeout runs out. */
|
|
11
|
+
client?: (url: string, key: string, options: {
|
|
12
|
+
deadline: Date;
|
|
13
|
+
}) => CiApi;
|
|
14
|
+
}
|
|
15
|
+
/** Runs one CLI command (#105). Returns the exit code (0, 1 or 2). */
|
|
16
|
+
export declare function runCli(argv: string[], io: CliIo): Promise<number>;
|