@hs-x/cli 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/kit.d.ts.map +1 -1
- package/dist/cli/kit.js +1 -1
- package/dist/cli/kit.js.map +1 -1
- package/dist/cloudflare-oauth.d.ts.map +1 -1
- package/dist/cloudflare-oauth.js +10 -9
- package/dist/cloudflare-oauth.js.map +1 -1
- package/dist/commands/connect.d.ts +2 -0
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +29 -1
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/control-plane-read.d.ts +60 -9
- package/dist/commands/control-plane-read.d.ts.map +1 -1
- package/dist/commands/control-plane-read.js +219 -26
- package/dist/commands/control-plane-read.js.map +1 -1
- package/dist/commands/deploy.d.ts +51 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +462 -23
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +156 -18
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/help-command.d.ts.map +1 -1
- package/dist/commands/help-command.js +33 -11
- package/dist/commands/help-command.js.map +1 -1
- package/dist/commands/migrate.d.ts +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +119 -11
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/status.d.ts +7 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +94 -6
- package/dist/commands/status.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/dev/invoke.d.ts +9 -0
- package/dist/dev/invoke.d.ts.map +1 -1
- package/dist/dev/invoke.js +67 -0
- package/dist/dev/invoke.js.map +1 -1
- package/dist/errors-registry.d.ts.map +1 -1
- package/dist/errors-registry.js +29 -0
- package/dist/errors-registry.js.map +1 -1
- package/dist/project-binding.d.ts +7 -1
- package/dist/project-binding.d.ts.map +1 -1
- package/dist/project-binding.js +10 -5
- package/dist/project-binding.js.map +1 -1
- package/package.json +7 -7
package/dist/commands/deploy.js
CHANGED
|
@@ -412,6 +412,11 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
412
412
|
projectId = answer;
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
|
+
if (projectId && accountId && !resolvedAcct.overrodeBinding) {
|
|
416
|
+
// Sticky binding, project half: project-scoped reads (status/logs/routes/
|
|
417
|
+
// drift) resolve their project id from here when no flag is passed.
|
|
418
|
+
await writeProjectBinding(root, accountId, projectId).catch(() => { });
|
|
419
|
+
}
|
|
415
420
|
if (!json && willChangeAnything && !flagYes && isInteractive()) {
|
|
416
421
|
const summary = buildDeployPlanSummary({
|
|
417
422
|
accountId,
|
|
@@ -437,13 +442,31 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
437
442
|
return cancelledResult(argv, 'deploy');
|
|
438
443
|
}
|
|
439
444
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
445
|
+
// The schema apply is the first phase that touches the portal. A failure here
|
|
446
|
+
// used to throw a raw HubSpot error out of the command — no HSX_E code, no
|
|
447
|
+
// phase/PLAN output (cold-stranger run #3, finding A). Render the curated
|
|
448
|
+
// failure WITH the plan that was being applied instead.
|
|
449
|
+
let portalSchemaApply;
|
|
450
|
+
if (applySchema) {
|
|
451
|
+
try {
|
|
452
|
+
portalSchemaApply = await applyPortalSchemaPlan({
|
|
453
|
+
argv,
|
|
454
|
+
plan: portalSchemaPlan,
|
|
455
|
+
source: portalSchemaRead?.source,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
return portalSchemaApplyFailure({
|
|
460
|
+
argv,
|
|
461
|
+
json,
|
|
462
|
+
root,
|
|
463
|
+
startedAt: commandStartedAt,
|
|
464
|
+
workers,
|
|
465
|
+
portalSchemaPlan,
|
|
466
|
+
error,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
447
470
|
const manifest = await generateProjectArtifacts({
|
|
448
471
|
root,
|
|
449
472
|
workers,
|
|
@@ -906,6 +929,11 @@ function renderDeployHuman(input) {
|
|
|
906
929
|
.map((worker) => `${worker.workerName}${worker.dryRun ? ' (dry-run)' : ''}`)
|
|
907
930
|
.join(', ');
|
|
908
931
|
reporter.info(`Cloudflare deploy: ${deployed}`);
|
|
932
|
+
for (const worker of input.cloudflareDeployResult.workers) {
|
|
933
|
+
if (worker.unauthenticatedInvoke) {
|
|
934
|
+
reporter.warn('HSX_W_DEPLOY_UNAUTHENTICATED_INVOKE', `${worker.workerName}: workflow-action invoke endpoints accept unsigned requests until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
909
937
|
}
|
|
910
938
|
if (!input.linked && input.cloudflareDeployResult) {
|
|
911
939
|
reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId)}`);
|
|
@@ -927,6 +955,22 @@ function renderDeployHuman(input) {
|
|
|
927
955
|
if (upload.deployId !== undefined) {
|
|
928
956
|
reporter.info(`HubSpot deploy id: ${upload.deployId}`);
|
|
929
957
|
}
|
|
958
|
+
else if (hubspotBuildStatus === 'SUCCESS' &&
|
|
959
|
+
upload.deploySkipped &&
|
|
960
|
+
upload.deployStatus !== undefined) {
|
|
961
|
+
const autoStatus = readStatus(upload.deployStatus) ?? 'UNKNOWN';
|
|
962
|
+
if (autoStatus === 'SUCCESS') {
|
|
963
|
+
reporter.info(`HubSpot deploy: auto-deploy SUCCESS (build #${upload.buildId})`);
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
reporter.warn('HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILED', `HubSpot auto-deploy finished ${autoStatus} — the build is green but the app did NOT ship. Check the deploy log in the HubSpot UI (project ${upload.projectName}).`);
|
|
967
|
+
for (const subbuild of readSubbuildStatuses(upload.deployStatus)) {
|
|
968
|
+
if (subbuild.status !== 'SUCCESS') {
|
|
969
|
+
reporter.info(` [x] ${subbuild.name}: ${subbuild.errorMessage ?? subbuild.status}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
930
974
|
else if (hubspotBuildStatus === 'SUCCESS' && upload.deploySkipped) {
|
|
931
975
|
reporter.info('HubSpot deploy: skipped (upload-only or auto-deploy handles it)');
|
|
932
976
|
}
|
|
@@ -1077,13 +1121,24 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
|
|
|
1077
1121
|
});
|
|
1078
1122
|
const applied = [];
|
|
1079
1123
|
const skipped = [];
|
|
1124
|
+
// Each HubSpot write is wrapped so a failure carries WHICH action failed, the
|
|
1125
|
+
// target object type (scope errors are per-object), and what already applied
|
|
1126
|
+
// before it — the curated failure renderer needs all three.
|
|
1127
|
+
const apply = async (label, objectType, run) => {
|
|
1128
|
+
try {
|
|
1129
|
+
await run();
|
|
1130
|
+
}
|
|
1131
|
+
catch (cause) {
|
|
1132
|
+
throw new PortalSchemaApplyError({ action: label, objectType, applied: [...applied], cause });
|
|
1133
|
+
}
|
|
1134
|
+
applied.push(label);
|
|
1135
|
+
};
|
|
1080
1136
|
for (const action of plan.actions) {
|
|
1081
1137
|
if (action.kind === 'will-create-object') {
|
|
1082
|
-
await client.schemaManagement.createSchema({
|
|
1138
|
+
await apply(`create-object:${action.objectType}`, action.objectType, () => client.schemaManagement.createSchema({
|
|
1083
1139
|
name: action.objectType,
|
|
1084
1140
|
labels: portalObjectLabels(action.objectType),
|
|
1085
|
-
});
|
|
1086
|
-
applied.push(`create-object:${action.objectType}`);
|
|
1141
|
+
}));
|
|
1087
1142
|
continue;
|
|
1088
1143
|
}
|
|
1089
1144
|
if (!action.propertyName || !action.declaredType) {
|
|
@@ -1092,25 +1147,23 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
|
|
|
1092
1147
|
}
|
|
1093
1148
|
if (action.kind === 'will-create-property') {
|
|
1094
1149
|
const fieldType = defaultHubSpotFieldType(action.declaredType);
|
|
1095
|
-
await client.schemaManagement.createProperty({
|
|
1150
|
+
await apply(`create-property:${action.objectType}.${action.propertyName}`, action.objectType, () => client.schemaManagement.createProperty({
|
|
1096
1151
|
objectType: action.objectType,
|
|
1097
1152
|
name: action.propertyName,
|
|
1098
1153
|
label: propertyLabel(action.propertyName),
|
|
1099
1154
|
type: action.declaredType,
|
|
1100
1155
|
...(fieldType ? { fieldType } : {}),
|
|
1101
|
-
});
|
|
1102
|
-
applied.push(`create-property:${action.objectType}.${action.propertyName}`);
|
|
1156
|
+
}));
|
|
1103
1157
|
continue;
|
|
1104
1158
|
}
|
|
1105
1159
|
if (action.kind === 'will-alter-property') {
|
|
1106
1160
|
const fieldType = defaultHubSpotFieldType(action.declaredType);
|
|
1107
|
-
await client.schemaManagement.updateProperty({
|
|
1161
|
+
await apply(`alter-property:${action.objectType}.${action.propertyName}`, action.objectType, () => client.schemaManagement.updateProperty({
|
|
1108
1162
|
objectType: action.objectType,
|
|
1109
1163
|
propertyName: action.propertyName,
|
|
1110
1164
|
type: action.declaredType,
|
|
1111
1165
|
...(fieldType ? { fieldType } : {}),
|
|
1112
|
-
});
|
|
1113
|
-
applied.push(`alter-property:${action.objectType}.${action.propertyName}`);
|
|
1166
|
+
}));
|
|
1114
1167
|
}
|
|
1115
1168
|
}
|
|
1116
1169
|
return {
|
|
@@ -1119,6 +1172,198 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
|
|
|
1119
1172
|
skipped,
|
|
1120
1173
|
};
|
|
1121
1174
|
}
|
|
1175
|
+
/** A portal-schema apply action failed against HubSpot. */
|
|
1176
|
+
class PortalSchemaApplyError extends Error {
|
|
1177
|
+
action;
|
|
1178
|
+
objectType;
|
|
1179
|
+
/** Actions that DID apply before the failure (partial-apply visibility). */
|
|
1180
|
+
applied;
|
|
1181
|
+
cause;
|
|
1182
|
+
constructor(input) {
|
|
1183
|
+
super(input.cause instanceof Error ? input.cause.message : String(input.cause));
|
|
1184
|
+
this.name = 'PortalSchemaApplyError';
|
|
1185
|
+
this.action = input.action;
|
|
1186
|
+
this.objectType = input.objectType;
|
|
1187
|
+
this.applied = input.applied;
|
|
1188
|
+
this.cause = input.cause;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* The raw HubSpot response body off a failed schema call. local-dev-lib wraps
|
|
1193
|
+
* axios failures in `HubSpotHttpError` and lifts `response.data` to `.data`;
|
|
1194
|
+
* a raw axios error keeps it at `.cause.response.data`.
|
|
1195
|
+
*/
|
|
1196
|
+
function hubSpotErrorBody(error) {
|
|
1197
|
+
if (!isRecord(error))
|
|
1198
|
+
return undefined;
|
|
1199
|
+
if (error.data !== undefined)
|
|
1200
|
+
return error.data;
|
|
1201
|
+
const cause = error.cause;
|
|
1202
|
+
if (isRecord(cause) && isRecord(cause.response))
|
|
1203
|
+
return cause.response.data;
|
|
1204
|
+
return undefined;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Extract HubSpot's MISSING_SCOPES payload. Captured live (cold-stranger run #3
|
|
1208
|
+
* repro — POST crm/v3/properties/contacts with a PAK lacking the write scope):
|
|
1209
|
+
*
|
|
1210
|
+
* { "status": "error",
|
|
1211
|
+
* "message": "This app hasn't been granted all required scopes to make this call. …",
|
|
1212
|
+
* "category": "MISSING_SCOPES",
|
|
1213
|
+
* "correlationId": "…",
|
|
1214
|
+
* "errors": [{ "message": "One or more of the following scopes are required.",
|
|
1215
|
+
* "context": { "requiredGranularScopes": ["crm.schemas.contacts.write", …] } }] }
|
|
1216
|
+
*
|
|
1217
|
+
* The list is ANY-OF (any one scope grants the call). Older error shapes use
|
|
1218
|
+
* `requiredScopes` (the only key local-dev-lib knows — which is why its own
|
|
1219
|
+
* message renders an EMPTY scope list for granular-scope errors).
|
|
1220
|
+
* Returns undefined when the error is not a scope error.
|
|
1221
|
+
*/
|
|
1222
|
+
function hubSpotMissingScopes(error) {
|
|
1223
|
+
const body = hubSpotErrorBody(error);
|
|
1224
|
+
if (!isRecord(body))
|
|
1225
|
+
return undefined;
|
|
1226
|
+
const scopes = [];
|
|
1227
|
+
if (Array.isArray(body.errors)) {
|
|
1228
|
+
for (const entry of body.errors) {
|
|
1229
|
+
if (!isRecord(entry) || !isRecord(entry.context))
|
|
1230
|
+
continue;
|
|
1231
|
+
for (const key of ['requiredGranularScopes', 'requiredScopes']) {
|
|
1232
|
+
const list = entry.context[key];
|
|
1233
|
+
if (!Array.isArray(list))
|
|
1234
|
+
continue;
|
|
1235
|
+
for (const scope of list) {
|
|
1236
|
+
if (typeof scope === 'string')
|
|
1237
|
+
scopes.push(scope);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (scopes.length === 0 && body.category !== 'MISSING_SCOPES')
|
|
1243
|
+
return undefined;
|
|
1244
|
+
return {
|
|
1245
|
+
scopes: [...new Set(scopes)],
|
|
1246
|
+
...(typeof body.correlationId === 'string' ? { correlationId: body.correlationId } : {}),
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
// Object types with a dedicated property-settings write scope
|
|
1250
|
+
// (crm.schemas.<type>.write — "create, delete, or make changes to property
|
|
1251
|
+
// settings"; crm.objects.<type>.write is RECORD writes and does not grant
|
|
1252
|
+
// property creation). Everything else falls under crm.schemas.custom.write.
|
|
1253
|
+
const HUBSPOT_SCHEMA_SCOPE_OBJECTS = new Set([
|
|
1254
|
+
'appointments',
|
|
1255
|
+
'carts',
|
|
1256
|
+
'commercepayments',
|
|
1257
|
+
'companies',
|
|
1258
|
+
'contacts',
|
|
1259
|
+
'courses',
|
|
1260
|
+
'deals',
|
|
1261
|
+
'invoices',
|
|
1262
|
+
'line_items',
|
|
1263
|
+
'listings',
|
|
1264
|
+
'orders',
|
|
1265
|
+
'quotes',
|
|
1266
|
+
'services',
|
|
1267
|
+
'subscriptions',
|
|
1268
|
+
'tickets',
|
|
1269
|
+
'users',
|
|
1270
|
+
]);
|
|
1271
|
+
/** The property-definition write scope `--apply-schema` needs for an object type. */
|
|
1272
|
+
function propertyWriteScopeFor(objectType) {
|
|
1273
|
+
return HUBSPOT_SCHEMA_SCOPE_OBJECTS.has(objectType)
|
|
1274
|
+
? `crm.schemas.${objectType}.write`
|
|
1275
|
+
: 'crm.schemas.custom.write';
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Narrow HubSpot's any-of scope list to what actually unblocks THIS apply:
|
|
1279
|
+
* the schemas-write scope for the target object when present, else every scope
|
|
1280
|
+
* that names the object, else the full response list (else the doc-grounded
|
|
1281
|
+
* default for the object type when the response carried none).
|
|
1282
|
+
*/
|
|
1283
|
+
function relevantMissingScopes(all, objectType) {
|
|
1284
|
+
const expected = propertyWriteScopeFor(objectType);
|
|
1285
|
+
if (all.includes(expected))
|
|
1286
|
+
return [expected];
|
|
1287
|
+
const mentioning = all.filter((scope) => scope.includes(`.${objectType}.`));
|
|
1288
|
+
if (mentioning.length > 0)
|
|
1289
|
+
return mentioning;
|
|
1290
|
+
return all.length > 0 ? all : [expected];
|
|
1291
|
+
}
|
|
1292
|
+
const HUBSPOT_PAK_URL = 'https://app.hubspot.com/l/personal-access-key';
|
|
1293
|
+
/**
|
|
1294
|
+
* Curated rendering for a failed `--apply-schema` leg (cold-stranger run #3,
|
|
1295
|
+
* finding A): the phase + PLAN output still prints, partial progress is shown,
|
|
1296
|
+
* and a HubSpot MISSING_SCOPES response becomes HSX_E_DEPLOY_SCHEMA_SCOPES
|
|
1297
|
+
* naming the exact scopes to grant — never a raw truncated HubSpot error.
|
|
1298
|
+
*/
|
|
1299
|
+
function portalSchemaApplyFailure(input) {
|
|
1300
|
+
const applyError = input.error instanceof PortalSchemaApplyError ? input.error : undefined;
|
|
1301
|
+
const cause = applyError?.cause ?? input.error;
|
|
1302
|
+
const scopeError = hubSpotMissingScopes(cause);
|
|
1303
|
+
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
1304
|
+
const hubSpotDetail = `${causeMessage.split('\n')[0]}${scopeError?.correlationId ? ` (correlationId ${scopeError.correlationId})` : ''}`;
|
|
1305
|
+
let code;
|
|
1306
|
+
let message;
|
|
1307
|
+
let hint;
|
|
1308
|
+
let missingScopes = [];
|
|
1309
|
+
if (scopeError && applyError) {
|
|
1310
|
+
code = 'HSX_E_DEPLOY_SCHEMA_SCOPES';
|
|
1311
|
+
missingScopes = relevantMissingScopes(scopeError.scopes, applyError.objectType);
|
|
1312
|
+
const scopesText = missingScopes.length === 1
|
|
1313
|
+
? `the ${missingScopes[0]} scope`
|
|
1314
|
+
: `one of these scopes: ${missingScopes.join(', ')}`;
|
|
1315
|
+
message = `HubSpot rejected ${applyError.action}: your personal access key cannot write ${applyError.objectType} property definitions. Grant ${scopesText}.`;
|
|
1316
|
+
hint = `Edit your personal access key at ${HUBSPOT_PAK_URL} and add ${missingScopes.length === 1 ? missingScopes[0] : 'one of the scopes above'}, then re-run. To preview without applying: hs-x deploy --plan --portal-schema-live.`;
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
code = 'HSX_E_DEPLOY_SCHEMA_APPLY';
|
|
1320
|
+
message = applyError
|
|
1321
|
+
? `Portal schema apply failed at ${applyError.action}: ${causeMessage.split('\n')[0]}`
|
|
1322
|
+
: causeMessage;
|
|
1323
|
+
hint = 'Preview the schema plan without applying: hs-x deploy --plan --portal-schema-live.';
|
|
1324
|
+
}
|
|
1325
|
+
const exitCode = code === 'HSX_E_DEPLOY_SCHEMA_SCOPES' ? 10 : 1;
|
|
1326
|
+
if (input.json) {
|
|
1327
|
+
write(`${JSON.stringify({
|
|
1328
|
+
schema_version: 1,
|
|
1329
|
+
command: 'deploy',
|
|
1330
|
+
ok: false,
|
|
1331
|
+
...(input.portalSchemaPlan ? { portalSchemaPlan: input.portalSchemaPlan } : {}),
|
|
1332
|
+
...(applyError && applyError.applied.length > 0
|
|
1333
|
+
? { portalSchemaApplied: applyError.applied }
|
|
1334
|
+
: {}),
|
|
1335
|
+
error: {
|
|
1336
|
+
code,
|
|
1337
|
+
message,
|
|
1338
|
+
hint,
|
|
1339
|
+
...(missingScopes.length > 0 ? { missing_scopes: missingScopes } : {}),
|
|
1340
|
+
detail: hubSpotDetail,
|
|
1341
|
+
},
|
|
1342
|
+
}, null, 2)}\n`);
|
|
1343
|
+
return { exitCode };
|
|
1344
|
+
}
|
|
1345
|
+
const reporter = createReporter({ command: 'deploy', argv: input.argv, startedAt: input.startedAt });
|
|
1346
|
+
reporter.header(basename(input.root));
|
|
1347
|
+
const capCount = input.workers.reduce((count, worker) => count + worker.capabilities.length, 0);
|
|
1348
|
+
reporter
|
|
1349
|
+
.step('Validating project')
|
|
1350
|
+
.ok(`${input.workers.length} workers, ${capCount} capabilities`);
|
|
1351
|
+
if (input.portalSchemaPlan) {
|
|
1352
|
+
reporter.info(renderPortalSchemaPlan(input.portalSchemaPlan).trimEnd());
|
|
1353
|
+
}
|
|
1354
|
+
if (applyError && applyError.applied.length > 0) {
|
|
1355
|
+
for (const done of applyError.applied)
|
|
1356
|
+
reporter.info(`APPLIED ${done}`);
|
|
1357
|
+
}
|
|
1358
|
+
reporter.step('Applying portal schema').fail(applyError?.action);
|
|
1359
|
+
reporter.error(code, message, {
|
|
1360
|
+
hint,
|
|
1361
|
+
cause: hubSpotDetail,
|
|
1362
|
+
docs_url: `https://hs-x.dev/errors/${code}`,
|
|
1363
|
+
});
|
|
1364
|
+
reporter.done(undefined, exitCode);
|
|
1365
|
+
return { exitCode };
|
|
1366
|
+
}
|
|
1122
1367
|
function renderPortalSchemaPlan(plan) {
|
|
1123
1368
|
if (plan.items.length === 0) {
|
|
1124
1369
|
return 'Portal schema: no changes or warnings.\n';
|
|
@@ -1367,6 +1612,37 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1367
1612
|
tenantDataPlane?.flagsKvNamespaceName,
|
|
1368
1613
|
tenantDataPlane?.tenantD1DatabaseName,
|
|
1369
1614
|
].filter((name) => typeof name === 'string' && name.toLowerCase().startsWith(accountResourcePrefix));
|
|
1615
|
+
// wrangler does NOT auto-apply D1 migrations on deploy (found live:
|
|
1616
|
+
// tenant DBs had zero tables across every real deploy since ADR-014) —
|
|
1617
|
+
// apply them explicitly before the worker code that expects them lands.
|
|
1618
|
+
const migrationsDirExists = await stat(join(root, 'node_modules', '@hs-x', 'runtime', 'migrations'))
|
|
1619
|
+
.then((entry) => entry.isDirectory())
|
|
1620
|
+
.catch(() => false);
|
|
1621
|
+
if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && !migrationsDirExists) {
|
|
1622
|
+
process.stderr.write('warning: @hs-x/runtime did not ship its tenant migrations (pre-0.2.4 package) — tenant D1 tables were NOT applied; upgrade @hs-x/runtime.\n');
|
|
1623
|
+
}
|
|
1624
|
+
if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && migrationsDirExists) {
|
|
1625
|
+
try {
|
|
1626
|
+
await runCloudflareCommand([
|
|
1627
|
+
'bun',
|
|
1628
|
+
'x',
|
|
1629
|
+
'wrangler',
|
|
1630
|
+
'd1',
|
|
1631
|
+
'migrations',
|
|
1632
|
+
'apply',
|
|
1633
|
+
tenantDataPlane.tenantD1DatabaseName,
|
|
1634
|
+
'--remote',
|
|
1635
|
+
'--config',
|
|
1636
|
+
relative(root, configPath),
|
|
1637
|
+
], { cwd: root, env: cloudflareDeployEnv(argv) });
|
|
1638
|
+
}
|
|
1639
|
+
catch (error) {
|
|
1640
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1641
|
+
if (!/No migrations to apply/i.test(message)) {
|
|
1642
|
+
throw new Error(`Tenant D1 migrations failed: ${message.slice(-300)}`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1370
1646
|
const command = [
|
|
1371
1647
|
'bun',
|
|
1372
1648
|
'x',
|
|
@@ -1470,13 +1746,29 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1470
1746
|
installKvNamespaceName: installRuntimeBinding.installKvNamespaceName,
|
|
1471
1747
|
tokenKeySecretName: installRuntimeBinding.tokenKeySecretName,
|
|
1472
1748
|
runtimeBaseUrl: resolvedRuntimeUrl,
|
|
1473
|
-
...(tenantDataPlane
|
|
1749
|
+
...(tenantDataPlane
|
|
1750
|
+
? {
|
|
1751
|
+
syncGrantSecretName: tenantDataPlane.syncGrantSecretName,
|
|
1752
|
+
// Read-through legs need these registered: exemplars read the
|
|
1753
|
+
// tenant D1; flag tooling reads the flags KV.
|
|
1754
|
+
tenantD1DatabaseId: tenantDataPlane.tenantD1DatabaseId,
|
|
1755
|
+
tenantD1DatabaseName: tenantDataPlane.tenantD1DatabaseName,
|
|
1756
|
+
flagsKvNamespaceId: tenantDataPlane.flagsKvNamespaceId,
|
|
1757
|
+
...(tenantDataPlane.flagsKvNamespaceName
|
|
1758
|
+
? { flagsKvNamespaceName: tenantDataPlane.flagsKvNamespaceName }
|
|
1759
|
+
: {}),
|
|
1760
|
+
}
|
|
1761
|
+
: {}),
|
|
1474
1762
|
});
|
|
1475
1763
|
}
|
|
1476
1764
|
catch (error) {
|
|
1477
1765
|
process.stderr.write(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1478
1766
|
}
|
|
1479
1767
|
}
|
|
1768
|
+
// Without the client secret on the Worker, the runtime accepts UNSIGNED
|
|
1769
|
+
// workflow-action invokes (HubSpot must still be able to call them pre-OAuth)
|
|
1770
|
+
// — surface that as a one-line warning instead of leaving it silent.
|
|
1771
|
+
const hasWorkflowActions = worker.capabilities.some((capability) => capability.kind === 'tool');
|
|
1480
1772
|
return {
|
|
1481
1773
|
workerName,
|
|
1482
1774
|
workerSourcePath: relative(root, workerSourcePath),
|
|
@@ -1487,6 +1779,9 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1487
1779
|
dryRun,
|
|
1488
1780
|
stdout: output.stdout,
|
|
1489
1781
|
stderr: output.stderr,
|
|
1782
|
+
...(hasWorkflowActions && !resolvedHubSpotClientSecret && !dryRun
|
|
1783
|
+
? { unauthenticatedInvoke: true }
|
|
1784
|
+
: {}),
|
|
1490
1785
|
};
|
|
1491
1786
|
}
|
|
1492
1787
|
async function discoverWorkerSourcePaths(root) {
|
|
@@ -1655,6 +1950,10 @@ async function putDeployInstallRuntimeBinding(input) {
|
|
|
1655
1950
|
tokenKeySecretName: input.tokenKeySecretName,
|
|
1656
1951
|
...(input.runtimeBaseUrl ? { runtimeBaseUrl: input.runtimeBaseUrl } : {}),
|
|
1657
1952
|
...(input.syncGrantSecretName ? { syncGrantSecretName: input.syncGrantSecretName } : {}),
|
|
1953
|
+
...(input.tenantD1DatabaseId ? { tenantD1DatabaseId: input.tenantD1DatabaseId } : {}),
|
|
1954
|
+
...(input.tenantD1DatabaseName ? { tenantD1DatabaseName: input.tenantD1DatabaseName } : {}),
|
|
1955
|
+
...(input.flagsKvNamespaceId ? { flagsKvNamespaceId: input.flagsKvNamespaceId } : {}),
|
|
1956
|
+
...(input.flagsKvNamespaceName ? { flagsKvNamespaceName: input.flagsKvNamespaceName } : {}),
|
|
1658
1957
|
},
|
|
1659
1958
|
});
|
|
1660
1959
|
const body = await response.json();
|
|
@@ -1836,6 +2135,14 @@ export function renderWranglerConfig(input) {
|
|
|
1836
2135
|
`name = "${tomlString(input.workerName)}"`,
|
|
1837
2136
|
`main = "${tomlString(input.entrypointPath)}"`,
|
|
1838
2137
|
`compatibility_date = "${tomlString(input.compatibilityDate)}"`,
|
|
2138
|
+
// nodejs_compat: guide + scaffold code reads `process.env.X` at module
|
|
2139
|
+
// scope (e.g. defineSource hmac secrets); without it the Worker fails
|
|
2140
|
+
// validation with "process is not defined" (Cloudflare error 10021,
|
|
2141
|
+
// cold-stranger run #2 blocker A). With nodejs_compat and a
|
|
2142
|
+
// compatibility_date >= 2025-04-01, Workers auto-populate process.env
|
|
2143
|
+
// from bindings/secrets; the explicit populate flag keeps that true even
|
|
2144
|
+
// when --compatibility-date is set to an older date.
|
|
2145
|
+
'compatibility_flags = [ "nodejs_compat", "nodejs_compat_populate_process_env" ]',
|
|
1839
2146
|
];
|
|
1840
2147
|
if (input.installKvNamespaceId) {
|
|
1841
2148
|
lines.push('', '[[kv_namespaces]]', 'binding = "INSTALL_KV"', `id = "${tomlString(input.installKvNamespaceId)}"`);
|
|
@@ -1891,7 +2198,7 @@ async function readDeployHubSpotOAuthSecret(input) {
|
|
|
1891
2198
|
function extractWorkerUrl(output) {
|
|
1892
2199
|
return /https:\/\/[a-zA-Z0-9.-]+\.workers\.dev\b/.exec(output)?.[0];
|
|
1893
2200
|
}
|
|
1894
|
-
async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, }) {
|
|
2201
|
+
export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, }) {
|
|
1895
2202
|
if (!needsRuntime) {
|
|
1896
2203
|
return;
|
|
1897
2204
|
}
|
|
@@ -1912,6 +2219,14 @@ async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers,
|
|
|
1912
2219
|
});
|
|
1913
2220
|
for (const [file, contents] of Object.entries(generated.files)) {
|
|
1914
2221
|
const path = join(root, file);
|
|
2222
|
+
// src/app/cards/package.json is user-owned once it exists: HubSpot's own
|
|
2223
|
+
// build errors instruct users to add card dependencies (e.g. hs-uix) to
|
|
2224
|
+
// it, and regenerating the fixed-dep version here silently dropped those
|
|
2225
|
+
// deps so the card never built (cold-stranger run #2 blocker B). Keep the
|
|
2226
|
+
// user's file verbatim; only generate one when absent.
|
|
2227
|
+
if (file === 'src/app/cards/package.json' && (await stat(path).catch(() => undefined))) {
|
|
2228
|
+
continue;
|
|
2229
|
+
}
|
|
1915
2230
|
await mkdir(dirname(path), { recursive: true });
|
|
1916
2231
|
await writeFile(path, contents);
|
|
1917
2232
|
}
|
|
@@ -2288,6 +2603,14 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
|
2288
2603
|
: 0;
|
|
2289
2604
|
if (appId)
|
|
2290
2605
|
await persistHubSpotAppBinding(root, { appId, projectName, developerAccountId });
|
|
2606
|
+
// Build SUCCESS does NOT mean the app shipped: HubSpot's auto-deploy can
|
|
2607
|
+
// still fail (found live: "Apps are not allowed to have 0 redirect URLs"
|
|
2608
|
+
// failed deploy #4 while the CLI reported green). Verify the deploy too.
|
|
2609
|
+
// Auto-deploy fires on uploads too — verify it regardless of uploadOnly
|
|
2610
|
+
// (the f1-data 0-redirect failure rode exactly this path).
|
|
2611
|
+
const autoDeployStatus = autoDeployEnabled && readStatus(buildStatus) === 'SUCCESS'
|
|
2612
|
+
? await waitForHubSpotAutoDeploy({ client, projectName, buildStatus })
|
|
2613
|
+
: undefined;
|
|
2291
2614
|
return {
|
|
2292
2615
|
projectName,
|
|
2293
2616
|
archivePath,
|
|
@@ -2297,7 +2620,7 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
|
2297
2620
|
buildId: upload.buildId,
|
|
2298
2621
|
deployId: undefined,
|
|
2299
2622
|
buildStatus,
|
|
2300
|
-
deployStatus:
|
|
2623
|
+
deployStatus: autoDeployStatus,
|
|
2301
2624
|
deploySkipped: Boolean(uploadOnly || autoDeployEnabled),
|
|
2302
2625
|
local,
|
|
2303
2626
|
};
|
|
@@ -2338,6 +2661,40 @@ async function optionalHubSpotAppId(client, projectName, appUid) {
|
|
|
2338
2661
|
return 0;
|
|
2339
2662
|
}
|
|
2340
2663
|
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Poll the auto-deploy triggered by a successful build until it reaches a
|
|
2666
|
+
* terminal state. The build status carries the deploy task locator
|
|
2667
|
+
* (deployStatusTaskLocator.id); absent/transient lookups resolve undefined —
|
|
2668
|
+
* the caller treats that as "could not verify", never as success.
|
|
2669
|
+
*/
|
|
2670
|
+
async function waitForHubSpotAutoDeploy({ client, projectName, buildStatus, timeoutMs = 60_000, intervalMs = 2_000, }) {
|
|
2671
|
+
const locator = buildStatus && typeof buildStatus === 'object'
|
|
2672
|
+
? buildStatus.deployStatusTaskLocator
|
|
2673
|
+
: undefined;
|
|
2674
|
+
const deployId = locator && typeof locator.id === 'number'
|
|
2675
|
+
? locator.id
|
|
2676
|
+
: locator && typeof locator.id === 'string'
|
|
2677
|
+
? Number.parseInt(locator.id, 10)
|
|
2678
|
+
: undefined;
|
|
2679
|
+
if (deployId === undefined || Number.isNaN(deployId))
|
|
2680
|
+
return undefined;
|
|
2681
|
+
const deadline = Date.now() + timeoutMs;
|
|
2682
|
+
let last;
|
|
2683
|
+
while (Date.now() < deadline) {
|
|
2684
|
+
try {
|
|
2685
|
+
last = await client.projects.getDeployStatus({ projectName, deployId });
|
|
2686
|
+
}
|
|
2687
|
+
catch {
|
|
2688
|
+
last = undefined;
|
|
2689
|
+
}
|
|
2690
|
+
const status = readStatus(last);
|
|
2691
|
+
if (status && status !== 'PENDING' && status !== 'BUILDING' && status !== 'DEPLOYING') {
|
|
2692
|
+
return last;
|
|
2693
|
+
}
|
|
2694
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
2695
|
+
}
|
|
2696
|
+
return last;
|
|
2697
|
+
}
|
|
2341
2698
|
async function waitForHubSpotBuildStatus({ client, projectName, buildId, timeoutMs, intervalMs, }) {
|
|
2342
2699
|
const startedAt = Date.now();
|
|
2343
2700
|
let latest;
|
|
@@ -2716,6 +3073,7 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
|
|
|
2716
3073
|
signedManifest: request.manifest,
|
|
2717
3074
|
signature: `sig_${plan.deployId}`,
|
|
2718
3075
|
bundleKey: deployBundleKey({ projectId: request.projectId, deployId: plan.deployId }),
|
|
3076
|
+
...(await collectDeployProvenance(userId)),
|
|
2719
3077
|
},
|
|
2720
3078
|
});
|
|
2721
3079
|
const body = await response.json();
|
|
@@ -2854,14 +3212,59 @@ function stringProperty(source, propertyName) {
|
|
|
2854
3212
|
return match ? { [propertyName]: match } : {};
|
|
2855
3213
|
}
|
|
2856
3214
|
function schemaProperty(source) {
|
|
2857
|
-
|
|
3215
|
+
// Balanced-brace extraction. The previous non-greedy regex stopped at the
|
|
3216
|
+
// first `}` inside an object-form field (`score: { type: "number", … }`),
|
|
3217
|
+
// truncating the schema and mis-reading nested keys (`type`) as property
|
|
3218
|
+
// names — which made portal-schema plans wrong for any schema that mixes
|
|
3219
|
+
// shorthand strings with object declarations.
|
|
3220
|
+
const body = objectLiteralBody(source, 'schema');
|
|
2858
3221
|
if (!body) {
|
|
2859
3222
|
return {};
|
|
2860
3223
|
}
|
|
2861
3224
|
const schema = {};
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
3225
|
+
let index = 0;
|
|
3226
|
+
while (index < body.length) {
|
|
3227
|
+
const entry = /^[\s,]*([A-Za-z_$][\w$]*)\s*:\s*/.exec(body.slice(index));
|
|
3228
|
+
if (!entry || !entry[1])
|
|
3229
|
+
break;
|
|
3230
|
+
const name = entry[1];
|
|
3231
|
+
const valueStart = index + entry[0].length;
|
|
3232
|
+
const char = body[valueStart];
|
|
3233
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
3234
|
+
const close = body.indexOf(char, valueStart + 1);
|
|
3235
|
+
if (close === -1)
|
|
3236
|
+
break;
|
|
3237
|
+
schema[name] = body.slice(valueStart + 1, close);
|
|
3238
|
+
index = close + 1;
|
|
3239
|
+
}
|
|
3240
|
+
else if (char === '{') {
|
|
3241
|
+
let depth = 0;
|
|
3242
|
+
let end = -1;
|
|
3243
|
+
for (let i = valueStart; i < body.length; i += 1) {
|
|
3244
|
+
if (body[i] === '{')
|
|
3245
|
+
depth += 1;
|
|
3246
|
+
else if (body[i] === '}') {
|
|
3247
|
+
depth -= 1;
|
|
3248
|
+
if (depth === 0) {
|
|
3249
|
+
end = i;
|
|
3250
|
+
break;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
if (end === -1)
|
|
3255
|
+
break;
|
|
3256
|
+
const inner = body.slice(valueStart + 1, end);
|
|
3257
|
+
const type = /type\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
|
|
3258
|
+
const property = /property\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
|
|
3259
|
+
schema[name] = { ...(type ? { type } : {}), ...(property ? { property } : {}) };
|
|
3260
|
+
index = end + 1;
|
|
3261
|
+
}
|
|
3262
|
+
else {
|
|
3263
|
+
// Unsupported value shape (identifier, number); skip to the next entry.
|
|
3264
|
+
const nextComma = body.indexOf(',', valueStart);
|
|
3265
|
+
if (nextComma === -1)
|
|
3266
|
+
break;
|
|
3267
|
+
index = nextComma + 1;
|
|
2865
3268
|
}
|
|
2866
3269
|
}
|
|
2867
3270
|
return Object.keys(schema).length > 0 ? { schema } : {};
|
|
@@ -3169,4 +3572,40 @@ const deployPromoteSubCmd = Command.make('promote', {
|
|
|
3169
3572
|
yield* runQuarantined(opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
3170
3573
|
})));
|
|
3171
3574
|
export const deployCmd = deployBaseCmd.pipe(Command.withSubcommands([deployPromoteSubCmd]));
|
|
3575
|
+
/**
|
|
3576
|
+
* Provenance for the active-deploy card, collected where it's cheapest: the
|
|
3577
|
+
* CLI knows its own version, the git state of the tree it deployed, and the
|
|
3578
|
+
* session identity. Everything best-effort — a non-git tree stamps nothing.
|
|
3579
|
+
*/
|
|
3580
|
+
async function collectDeployProvenance(userId) {
|
|
3581
|
+
const git = async (args) => {
|
|
3582
|
+
try {
|
|
3583
|
+
const proc = Bun.spawn({ cmd: ['git', ...args], stdout: 'pipe', stderr: 'ignore' });
|
|
3584
|
+
const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
3585
|
+
const trimmed = out.trim();
|
|
3586
|
+
return code === 0 && trimmed ? trimmed : undefined;
|
|
3587
|
+
}
|
|
3588
|
+
catch {
|
|
3589
|
+
return undefined;
|
|
3590
|
+
}
|
|
3591
|
+
};
|
|
3592
|
+
const [gitCommit, gitBranch] = await Promise.all([
|
|
3593
|
+
git(['rev-parse', '--short', 'HEAD']),
|
|
3594
|
+
git(['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
3595
|
+
]);
|
|
3596
|
+
let deployedBy;
|
|
3597
|
+
try {
|
|
3598
|
+
const { loadStore, activeSession } = await import('../account-store.js');
|
|
3599
|
+
deployedBy = activeSession(await loadStore())?.email ?? userId;
|
|
3600
|
+
}
|
|
3601
|
+
catch {
|
|
3602
|
+
deployedBy = userId;
|
|
3603
|
+
}
|
|
3604
|
+
return {
|
|
3605
|
+
cliVersion: `hs-x@${CLI_VERSION}`,
|
|
3606
|
+
...(gitCommit ? { gitCommit } : {}),
|
|
3607
|
+
...(gitBranch ? { gitBranch } : {}),
|
|
3608
|
+
...(deployedBy ? { deployedBy } : {}),
|
|
3609
|
+
};
|
|
3610
|
+
}
|
|
3172
3611
|
//# sourceMappingURL=deploy.js.map
|