@theholocron/cli 4.19.0 → 5.0.0-alpha.100
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/README.md +110 -2
- package/dist/cli.mjs +1177 -160
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +211 -13
- package/dist/index.mjs +235 -3
- package/dist/plugin/capabilities.d.mts +91 -23
- package/package.json +19 -15
package/dist/cli.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import path, { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { checkbox, input, select } from "@inquirer/prompts";
|
|
6
6
|
import { KNOWN_WORKFLOWS, createAstromech, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
|
|
7
7
|
import { loadTasksConfig } from "@theholocron/astromech/config";
|
|
@@ -24,6 +24,8 @@ import { createGitHubClient } from "@theholocron/github-client";
|
|
|
24
24
|
import { PostHogSink } from "@theholocron/observability/analytics";
|
|
25
25
|
import { NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
|
|
26
26
|
import { SentrySink } from "@theholocron/observability/errors";
|
|
27
|
+
import load from "@commitlint/load";
|
|
28
|
+
import lint from "@commitlint/lint";
|
|
27
29
|
import { promisify } from "node:util";
|
|
28
30
|
import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
|
|
29
31
|
import search from "@inquirer/search";
|
|
@@ -295,7 +297,8 @@ function resolveConfig(raw) {
|
|
|
295
297
|
docs,
|
|
296
298
|
env: raw.env,
|
|
297
299
|
log: raw.log,
|
|
298
|
-
telemetry: raw.telemetry
|
|
300
|
+
telemetry: raw.telemetry,
|
|
301
|
+
eslint: raw.eslint
|
|
299
302
|
};
|
|
300
303
|
}
|
|
301
304
|
//#endregion
|
|
@@ -532,7 +535,8 @@ var PluginLoader = class {
|
|
|
532
535
|
/** Default importer — resolves from cwd first so the global CLI finds project plugins. */
|
|
533
536
|
const defaultImporter$1 = async (pkg) => {
|
|
534
537
|
try {
|
|
535
|
-
|
|
538
|
+
const resolved = createRequire(process.cwd() + "/").resolve(pkg);
|
|
539
|
+
return import(pathToFileURL(resolved).href);
|
|
536
540
|
} catch {
|
|
537
541
|
return import(pkg);
|
|
538
542
|
}
|
|
@@ -664,7 +668,8 @@ const style = {
|
|
|
664
668
|
*/
|
|
665
669
|
const defaultImporter = async (pkg) => {
|
|
666
670
|
try {
|
|
667
|
-
|
|
671
|
+
const resolved = createRequire(process.cwd() + "/").resolve(pkg);
|
|
672
|
+
return await import(pathToFileURL(resolved).href);
|
|
668
673
|
} catch {
|
|
669
674
|
return await import(pkg);
|
|
670
675
|
}
|
|
@@ -1106,8 +1111,10 @@ async function runClone(input) {
|
|
|
1106
1111
|
}).status };
|
|
1107
1112
|
});
|
|
1108
1113
|
print(style.header(`Holocron clone — org=${input.org} → ${targetDir}${dryRun ? " (dry-run)" : ""}`));
|
|
1109
|
-
if (!existsSync(targetDir))
|
|
1110
|
-
|
|
1114
|
+
if (!existsSync(targetDir)) {
|
|
1115
|
+
if (dryRun) print(style.dim(` would create ${targetDir}`));
|
|
1116
|
+
else mkdirSync(targetDir, { recursive: true });
|
|
1117
|
+
}
|
|
1111
1118
|
let repos;
|
|
1112
1119
|
try {
|
|
1113
1120
|
repos = await listOrgRepos(input.org, input.token, fetchFn);
|
|
@@ -1129,7 +1136,8 @@ async function runClone(input) {
|
|
|
1129
1136
|
let skipped = 0;
|
|
1130
1137
|
let failed = 0;
|
|
1131
1138
|
for (const repo of repos) {
|
|
1132
|
-
const
|
|
1139
|
+
const dirName = repo.name.startsWith(".") ? repo.name.slice(1) : repo.name;
|
|
1140
|
+
const dest = join(targetDir, dirName);
|
|
1133
1141
|
if (existsSync(dest)) {
|
|
1134
1142
|
print(style.dim(` skip ${repo.full_name}`));
|
|
1135
1143
|
skipped++;
|
|
@@ -1214,6 +1222,7 @@ const COMMAND_CONTEXTS = {
|
|
|
1214
1222
|
"skills install": "global",
|
|
1215
1223
|
"skills remove": "global",
|
|
1216
1224
|
"skills update": "global",
|
|
1225
|
+
"lint commit-msg": "global",
|
|
1217
1226
|
run: "repo-aware",
|
|
1218
1227
|
ci: "repo-aware",
|
|
1219
1228
|
"config show": "repo-aware",
|
|
@@ -1233,6 +1242,80 @@ function commandsInContext(context) {
|
|
|
1233
1242
|
}
|
|
1234
1243
|
//#endregion
|
|
1235
1244
|
//#region src/commands/deploy.ts
|
|
1245
|
+
/**
|
|
1246
|
+
* `holocron deploy` — trigger a deployment via the configured
|
|
1247
|
+
* `deployment` capability.
|
|
1248
|
+
*
|
|
1249
|
+
* Two modes, one command: `runDeploy` (the default — a Git branch,
|
|
1250
|
+
* `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
|
|
1251
|
+
* a local directory of source files, `deployFunction()`, no linked
|
|
1252
|
+
* repo required — for a consumer with no Git history to deploy from,
|
|
1253
|
+
* e.g. Sentinel, shipped as an npm package). Separate functions, not
|
|
1254
|
+
* one branching `runDeploy`, because their inputs genuinely differ
|
|
1255
|
+
* (`branch` vs `dir`) and their result shapes differ too
|
|
1256
|
+
* (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
|
|
1257
|
+
* is deliberately minimal, same as `DeployScriptResult` for Workers).
|
|
1258
|
+
*
|
|
1259
|
+
* Errors clearly when `deployment` isn't loaded (no provider declared
|
|
1260
|
+
* in `holocron.config.json`), or when the configured provider doesn't
|
|
1261
|
+
* implement `deployFunction` (it's optional on `Deployment` — not
|
|
1262
|
+
* every provider has a files-based deploy API).
|
|
1263
|
+
*
|
|
1264
|
+
* Dry-run skips the actual deploy call but still verifies the
|
|
1265
|
+
* deployment capability (and, for `--files`, `deployFunction`) is
|
|
1266
|
+
* present, so operators can sanity-check the wiring without spinning
|
|
1267
|
+
* up a build.
|
|
1268
|
+
*
|
|
1269
|
+
* **Both modes wait for the deployment to finish** (holocron#911): a
|
|
1270
|
+
* provider accepting the upload/trigger is not the deployment succeeding.
|
|
1271
|
+
* Vercel builds asynchronously — an `npm install` failure there leaves
|
|
1272
|
+
* the deployment in `ERROR` while production keeps serving the previous
|
|
1273
|
+
* one, and this command used to report `ok` regardless (found live:
|
|
1274
|
+
* Sentinel's holocron#910 deploy "succeeded" in CI, never went live). So
|
|
1275
|
+
* after triggering, {@link waitForDeployment} polls
|
|
1276
|
+
* `Deployment.getDeployment()` until a terminal state; only `ready` is
|
|
1277
|
+
* `ok`, while `error` / `cancelled` / a timeout are `fail` (non-zero exit
|
|
1278
|
+
* via `cli.ts`).
|
|
1279
|
+
*/
|
|
1280
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 6e5;
|
|
1281
|
+
const DEFAULT_WAIT_INTERVAL_MS = 5e3;
|
|
1282
|
+
const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
1283
|
+
"ready",
|
|
1284
|
+
"error",
|
|
1285
|
+
"cancelled"
|
|
1286
|
+
]);
|
|
1287
|
+
/**
|
|
1288
|
+
* Polls `getDeployment(id)` until the deployment reaches a terminal status
|
|
1289
|
+
* (`ready` / `error` / `cancelled`). Throws on timeout — the caller turns
|
|
1290
|
+
* that into a `fail` report, same as a provider throwing mid-trigger.
|
|
1291
|
+
*/
|
|
1292
|
+
async function waitForDeployment(deploy, deploymentId, options = {}) {
|
|
1293
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
1294
|
+
const intervalMs = options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
|
|
1295
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1296
|
+
const now = options.now ?? Date.now;
|
|
1297
|
+
const deadline = now() + timeoutMs;
|
|
1298
|
+
for (;;) {
|
|
1299
|
+
const record = await deploy.getDeployment(deploymentId);
|
|
1300
|
+
if (TERMINAL_STATUSES.has(record.status)) return record;
|
|
1301
|
+
if (now() >= deadline) throw new Error(`deployment ${deploymentId} still ${record.status} after ${Math.round(timeoutMs / 1e3)}s — check the provider's dashboard`);
|
|
1302
|
+
await sleep(intervalMs);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** One terminal, non-ready record as a single human-readable failure line. */
|
|
1306
|
+
function terminalFailure(record) {
|
|
1307
|
+
return `deployment ${record.id} ended ${record.status}${record.errorMessage ? `: ${record.errorMessage}` : ""}`;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Provider `url` fields (Vercel's REST API, at least) come back as bare
|
|
1311
|
+
* hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
|
|
1312
|
+
* linkify and browsers won't navigate to unmodified. Printed output
|
|
1313
|
+
* should be a URL a human can click/paste; structured log fields keep
|
|
1314
|
+
* the raw provider value as-is.
|
|
1315
|
+
*/
|
|
1316
|
+
function withScheme(url) {
|
|
1317
|
+
return /^https?:\/\//.test(url) ? url : `https://${url}`;
|
|
1318
|
+
}
|
|
1236
1319
|
async function runDeploy(input) {
|
|
1237
1320
|
const print = input.print ?? ((line) => console.log(line));
|
|
1238
1321
|
const logger = input.logger ?? getLogger();
|
|
@@ -1259,17 +1342,37 @@ async function runDeploy(input) {
|
|
|
1259
1342
|
};
|
|
1260
1343
|
}
|
|
1261
1344
|
try {
|
|
1262
|
-
const
|
|
1345
|
+
const triggered = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
|
|
1263
1346
|
projectId: input.projectId,
|
|
1264
1347
|
branch: input.branch,
|
|
1265
1348
|
...input.target ? { target: input.target } : {}
|
|
1266
1349
|
}));
|
|
1267
|
-
|
|
1350
|
+
logger.info({
|
|
1351
|
+
status: triggered.status,
|
|
1352
|
+
url: triggered.url,
|
|
1353
|
+
id: triggered.id
|
|
1354
|
+
}, "deploy: triggered");
|
|
1355
|
+
const record = TERMINAL_STATUSES.has(triggered.status) ? triggered : await withSpinner(`Waiting for ${withScheme(triggered.url)} to finish…`, () => waitForDeployment(deploy, triggered.id, input.wait));
|
|
1356
|
+
if (record.status !== "ready") {
|
|
1357
|
+
const message = terminalFailure(record);
|
|
1358
|
+
print(` ${style.fail(`${message} — ${withScheme(triggered.url)}`)}`);
|
|
1359
|
+
logger.warn({
|
|
1360
|
+
id: record.id,
|
|
1361
|
+
status: record.status,
|
|
1362
|
+
reason: record.errorMessage
|
|
1363
|
+
}, "deploy: failed");
|
|
1364
|
+
return {
|
|
1365
|
+
deployment: record,
|
|
1366
|
+
status: "fail",
|
|
1367
|
+
message
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
print(` ${style.success(`${record.status} — ${withScheme(triggered.url)}`)}`);
|
|
1268
1371
|
logger.info({
|
|
1269
1372
|
status: record.status,
|
|
1270
|
-
url:
|
|
1373
|
+
url: triggered.url,
|
|
1271
1374
|
id: record.id
|
|
1272
|
-
}, "deploy:
|
|
1375
|
+
}, "deploy: ready");
|
|
1273
1376
|
return {
|
|
1274
1377
|
deployment: record,
|
|
1275
1378
|
status: "ok"
|
|
@@ -1288,6 +1391,106 @@ async function runDeploy(input) {
|
|
|
1288
1391
|
};
|
|
1289
1392
|
}
|
|
1290
1393
|
}
|
|
1394
|
+
const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
|
|
1395
|
+
".git",
|
|
1396
|
+
"node_modules",
|
|
1397
|
+
".turbo"
|
|
1398
|
+
]);
|
|
1399
|
+
/** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
|
|
1400
|
+
function defaultWalkFiles$2(dir) {
|
|
1401
|
+
const results = [];
|
|
1402
|
+
for (const entry of readdirSync(dir)) {
|
|
1403
|
+
if (SKIP_DIRS$2.has(entry)) continue;
|
|
1404
|
+
const full = join(dir, entry);
|
|
1405
|
+
const stat = statSync(full);
|
|
1406
|
+
if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
|
|
1407
|
+
else if (stat.isFile()) results.push(full);
|
|
1408
|
+
}
|
|
1409
|
+
return results;
|
|
1410
|
+
}
|
|
1411
|
+
/** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
|
|
1412
|
+
async function runDeployFromFiles(input) {
|
|
1413
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
1414
|
+
const logger = input.logger ?? getLogger();
|
|
1415
|
+
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
1416
|
+
await loader.load();
|
|
1417
|
+
assertPluginsResolvable(loader, "deploy");
|
|
1418
|
+
const dryRun = input.context.dryRun ?? false;
|
|
1419
|
+
const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
|
|
1420
|
+
const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
1421
|
+
const files = {};
|
|
1422
|
+
for (const abs of walkFiles(input.dir)) {
|
|
1423
|
+
const rel = relative(input.dir, abs).split(sep).join("/");
|
|
1424
|
+
files[rel] = readFile(abs);
|
|
1425
|
+
}
|
|
1426
|
+
const fileCount = Object.keys(files).length;
|
|
1427
|
+
logger.info({
|
|
1428
|
+
dir: input.dir,
|
|
1429
|
+
fileCount,
|
|
1430
|
+
target: input.target ?? "preview",
|
|
1431
|
+
projectId: input.projectId,
|
|
1432
|
+
dryRun: dryRun || void 0
|
|
1433
|
+
}, "deploy: start (files)");
|
|
1434
|
+
print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
|
|
1435
|
+
if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
|
|
1436
|
+
const deploy = loader.get("deployment");
|
|
1437
|
+
if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
|
|
1438
|
+
if (dryRun) {
|
|
1439
|
+
const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
|
|
1440
|
+
print(` ${style.dim(`… ${message}`)}`);
|
|
1441
|
+
return {
|
|
1442
|
+
deployment: null,
|
|
1443
|
+
status: "dry-run",
|
|
1444
|
+
message
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
try {
|
|
1448
|
+
const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
|
|
1449
|
+
files,
|
|
1450
|
+
...input.target ? { target: input.target } : {}
|
|
1451
|
+
}));
|
|
1452
|
+
logger.info({
|
|
1453
|
+
url: result.url,
|
|
1454
|
+
id: result.deploymentId
|
|
1455
|
+
}, "deploy: triggered (files)");
|
|
1456
|
+
const record = await withSpinner(`Waiting for ${withScheme(result.url)} to finish…`, () => waitForDeployment(deploy, result.deploymentId, input.wait));
|
|
1457
|
+
if (record.status !== "ready") {
|
|
1458
|
+
const message = terminalFailure(record);
|
|
1459
|
+
print(` ${style.fail(`${message} — ${withScheme(result.url)}`)}`);
|
|
1460
|
+
logger.warn({
|
|
1461
|
+
id: record.id,
|
|
1462
|
+
status: record.status,
|
|
1463
|
+
reason: record.errorMessage
|
|
1464
|
+
}, "deploy: failed (files)");
|
|
1465
|
+
return {
|
|
1466
|
+
deployment: result,
|
|
1467
|
+
status: "fail",
|
|
1468
|
+
message
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
print(` ${style.success(withScheme(result.url))}`);
|
|
1472
|
+
logger.info({
|
|
1473
|
+
url: result.url,
|
|
1474
|
+
id: result.deploymentId
|
|
1475
|
+
}, "deploy: ready (files)");
|
|
1476
|
+
return {
|
|
1477
|
+
deployment: result,
|
|
1478
|
+
status: "ok"
|
|
1479
|
+
};
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1482
|
+
print(` ${style.fail(message)}`);
|
|
1483
|
+
logger.warn({
|
|
1484
|
+
dir: input.dir,
|
|
1485
|
+
reason: message
|
|
1486
|
+
}, "deploy: failed (files)");
|
|
1487
|
+
return {
|
|
1488
|
+
deployment: null,
|
|
1489
|
+
status: "fail",
|
|
1490
|
+
message
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1291
1494
|
//#endregion
|
|
1292
1495
|
//#region src/commands/doctor.ts
|
|
1293
1496
|
async function runDoctor(input) {
|
|
@@ -2154,12 +2357,11 @@ function render$10(inputs) {
|
|
|
2154
2357
|
main: "./src/index.ts",
|
|
2155
2358
|
exports: { ".": "./src/index.ts" },
|
|
2156
2359
|
scripts: {
|
|
2157
|
-
build: "tsdown",
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2360
|
+
"delivery.build": "tsdown",
|
|
2361
|
+
"sourceQuality.staticAnalysis": "holocron run sourceQuality.staticAnalysis --",
|
|
2362
|
+
"verification.typeSafety": "holocron run verification.typeSafety --",
|
|
2363
|
+
"verification.unitTests": "holocron run verification.unitTests --",
|
|
2161
2364
|
"test:watch": "vitest",
|
|
2162
|
-
"test:coverage": "vitest run --coverage",
|
|
2163
2365
|
validate: "tsx scripts/validate.mjs"
|
|
2164
2366
|
},
|
|
2165
2367
|
peerDependencies: { "@theholocron/cli": "workspace:*" },
|
|
@@ -2879,7 +3081,7 @@ function runPluginCreate(input) {
|
|
|
2879
3081
|
execFn("pnpm", [
|
|
2880
3082
|
"--filter",
|
|
2881
3083
|
pkg,
|
|
2882
|
-
"
|
|
3084
|
+
"verification.typeSafety"
|
|
2883
3085
|
], {
|
|
2884
3086
|
cwd,
|
|
2885
3087
|
stdio: "inherit"
|
|
@@ -2887,7 +3089,7 @@ function runPluginCreate(input) {
|
|
|
2887
3089
|
execFn("pnpm", [
|
|
2888
3090
|
"--filter",
|
|
2889
3091
|
pkg,
|
|
2890
|
-
"
|
|
3092
|
+
"sourceQuality.staticAnalysis"
|
|
2891
3093
|
], {
|
|
2892
3094
|
cwd,
|
|
2893
3095
|
stdio: "inherit"
|
|
@@ -2895,7 +3097,7 @@ function runPluginCreate(input) {
|
|
|
2895
3097
|
execFn("pnpm", [
|
|
2896
3098
|
"--filter",
|
|
2897
3099
|
pkg,
|
|
2898
|
-
"
|
|
3100
|
+
"verification.unitTests"
|
|
2899
3101
|
], {
|
|
2900
3102
|
cwd,
|
|
2901
3103
|
stdio: "inherit"
|
|
@@ -3018,6 +3220,193 @@ function printNextSteps$1(print, inputs) {
|
|
|
3018
3220
|
* "revoke this token at <url>" reminder — same pattern as `rando vc
|
|
3019
3221
|
* setup` for the ephemeral GH admin PAT.
|
|
3020
3222
|
*/
|
|
3223
|
+
/**
|
|
3224
|
+
* `holocron publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
|
|
3225
|
+
* Trusted Publisher (OIDC) config to a new workflow filename across every
|
|
3226
|
+
* public package. See issue #689 / `scripts/npm-trust-migrate.sh` (the
|
|
3227
|
+
* shell-script prototype this supersedes) for the full "why": npm ties
|
|
3228
|
+
* Trusted Publisher to a workflow FILENAME per package, so renaming the
|
|
3229
|
+
* release workflow breaks OIDC publish (`ENEEDAUTH`) until every package's
|
|
3230
|
+
* config is updated to match.
|
|
3231
|
+
*
|
|
3232
|
+
* Requires npm@11.15.0+ (`npm trust`) — always shells out through
|
|
3233
|
+
* `npx -y npm@latest` regardless of the locally-installed npm version.
|
|
3234
|
+
*
|
|
3235
|
+
* npm's `trust` mutations — and reads, once a short-lived OTP grant lapses —
|
|
3236
|
+
* require a fresh interactive browser 2FA approval (an `EOTP` error with an
|
|
3237
|
+
* `authUrl`). No token bypasses this (npm's own docs: Granular Access Tokens
|
|
3238
|
+
* with the bypass-2FA option are explicitly unsupported for trust commands).
|
|
3239
|
+
* This command can't drive that browser step itself, so on the first `EOTP`
|
|
3240
|
+
* it stops immediately, prints the `authUrl`, and returns — the operator
|
|
3241
|
+
* approves it and re-runs; already-migrated packages are skipped on the next
|
|
3242
|
+
* pass, so it's safe to resume from wherever it stopped.
|
|
3243
|
+
*/
|
|
3244
|
+
async function runSyncTrust(input) {
|
|
3245
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
3246
|
+
const logger = input.logger ?? getLogger();
|
|
3247
|
+
const cwd = input.cwd ?? process.cwd();
|
|
3248
|
+
const repo = input.repo ?? "theholocron/holocron";
|
|
3249
|
+
const dryRun = input.dryRun ?? false;
|
|
3250
|
+
const exec = input.exec ?? defaultExec$1;
|
|
3251
|
+
const packageNames = input.packages ?? discoverPublicPackages(cwd);
|
|
3252
|
+
const npm = "npx";
|
|
3253
|
+
const npmArgs = ["-y", "npm@latest"];
|
|
3254
|
+
print(`Holocron publish --sync-trust${dryRun ? " (dry-run)" : ""}`);
|
|
3255
|
+
print(` ${input.oldFile} -> ${input.newFile} (repo: ${repo})`);
|
|
3256
|
+
print(` ${packageNames.length} public packages`);
|
|
3257
|
+
print("");
|
|
3258
|
+
logger.info({
|
|
3259
|
+
oldFile: input.oldFile,
|
|
3260
|
+
newFile: input.newFile,
|
|
3261
|
+
packages: packageNames.length
|
|
3262
|
+
}, "sync-trust: start");
|
|
3263
|
+
if (packageNames.length === 0) {
|
|
3264
|
+
const message = "nothing to sync (no packages/* and root package.json is private or unnamed)";
|
|
3265
|
+
print(` ✗ ${message}`);
|
|
3266
|
+
return {
|
|
3267
|
+
status: "fail",
|
|
3268
|
+
message,
|
|
3269
|
+
packages: []
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
if (dryRun) {
|
|
3273
|
+
for (const name of packageNames) print(` … would sync ${name}`);
|
|
3274
|
+
return {
|
|
3275
|
+
status: "dry-run",
|
|
3276
|
+
message: "dry-run — no npm calls made",
|
|
3277
|
+
packages: []
|
|
3278
|
+
};
|
|
3279
|
+
}
|
|
3280
|
+
const results = [];
|
|
3281
|
+
for (const name of packageNames) {
|
|
3282
|
+
print(`=== ${name} ===`);
|
|
3283
|
+
const parsed = parseJson((await exec(npm, [
|
|
3284
|
+
...npmArgs,
|
|
3285
|
+
"trust",
|
|
3286
|
+
"list",
|
|
3287
|
+
name,
|
|
3288
|
+
"--json"
|
|
3289
|
+
], { cwd })).stdout);
|
|
3290
|
+
if (isEotp(parsed)) {
|
|
3291
|
+
const authUrl = parsed.error?.authUrl ?? "";
|
|
3292
|
+
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3293
|
+
print(" then re-run; already-migrated packages are skipped.");
|
|
3294
|
+
results.push({
|
|
3295
|
+
name,
|
|
3296
|
+
status: "needs-auth",
|
|
3297
|
+
message: authUrl
|
|
3298
|
+
});
|
|
3299
|
+
logger.warn({
|
|
3300
|
+
package: name,
|
|
3301
|
+
authUrl
|
|
3302
|
+
}, "sync-trust: needs auth, stopping");
|
|
3303
|
+
return {
|
|
3304
|
+
status: "fail",
|
|
3305
|
+
message: "npm 2FA grant needed — see printed authUrl",
|
|
3306
|
+
packages: results
|
|
3307
|
+
};
|
|
3308
|
+
}
|
|
3309
|
+
const entry = parsed;
|
|
3310
|
+
if (entry.file === input.newFile) {
|
|
3311
|
+
print(` already on ${input.newFile}, skipping`);
|
|
3312
|
+
results.push({
|
|
3313
|
+
name,
|
|
3314
|
+
status: "skipped"
|
|
3315
|
+
});
|
|
3316
|
+
continue;
|
|
3317
|
+
}
|
|
3318
|
+
if (entry.id) {
|
|
3319
|
+
print(` revoking existing trust (file=${entry.file ?? "?"}, id=${entry.id})`);
|
|
3320
|
+
const revoke = await exec(npm, [
|
|
3321
|
+
...npmArgs,
|
|
3322
|
+
"trust",
|
|
3323
|
+
"revoke",
|
|
3324
|
+
name,
|
|
3325
|
+
"--id",
|
|
3326
|
+
entry.id,
|
|
3327
|
+
"-y"
|
|
3328
|
+
], { cwd });
|
|
3329
|
+
if (revoke.exitCode !== 0 && isEotp(parseJson(revoke.stdout))) {
|
|
3330
|
+
const authUrl = parseJson(revoke.stdout).error?.authUrl ?? "";
|
|
3331
|
+
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3332
|
+
results.push({
|
|
3333
|
+
name,
|
|
3334
|
+
status: "needs-auth",
|
|
3335
|
+
message: authUrl
|
|
3336
|
+
});
|
|
3337
|
+
return {
|
|
3338
|
+
status: "fail",
|
|
3339
|
+
message: "npm 2FA grant needed — see printed authUrl",
|
|
3340
|
+
packages: results
|
|
3341
|
+
};
|
|
3342
|
+
}
|
|
3343
|
+
} else print(" no existing trust config found");
|
|
3344
|
+
print(` creating trust: ${input.newFile}`);
|
|
3345
|
+
const create = await exec(npm, [
|
|
3346
|
+
...npmArgs,
|
|
3347
|
+
"trust",
|
|
3348
|
+
"github",
|
|
3349
|
+
name,
|
|
3350
|
+
"--repo",
|
|
3351
|
+
repo,
|
|
3352
|
+
"--file",
|
|
3353
|
+
input.newFile,
|
|
3354
|
+
"--allow-publish",
|
|
3355
|
+
"--allow-stage-publish",
|
|
3356
|
+
"-y"
|
|
3357
|
+
], { cwd });
|
|
3358
|
+
if (create.exitCode === 0) {
|
|
3359
|
+
print(" ✓ ok");
|
|
3360
|
+
results.push({
|
|
3361
|
+
name,
|
|
3362
|
+
status: "ok"
|
|
3363
|
+
});
|
|
3364
|
+
} else {
|
|
3365
|
+
const createParsed = parseJson(create.stdout);
|
|
3366
|
+
if (isEotp(createParsed)) {
|
|
3367
|
+
const authUrl = createParsed.error?.authUrl ?? "";
|
|
3368
|
+
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3369
|
+
results.push({
|
|
3370
|
+
name,
|
|
3371
|
+
status: "needs-auth",
|
|
3372
|
+
message: authUrl
|
|
3373
|
+
});
|
|
3374
|
+
return {
|
|
3375
|
+
status: "fail",
|
|
3376
|
+
message: "npm 2FA grant needed — see printed authUrl",
|
|
3377
|
+
packages: results
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
const message = create.stderr.trim() || create.stdout.trim() || `exit ${create.exitCode}`;
|
|
3381
|
+
print(` ✗ FAILED: ${message}`);
|
|
3382
|
+
results.push({
|
|
3383
|
+
name,
|
|
3384
|
+
status: "fail",
|
|
3385
|
+
message
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
const failed = results.filter((r) => r.status === "fail");
|
|
3390
|
+
logger.info({
|
|
3391
|
+
ok: results.filter((r) => r.status === "ok").length,
|
|
3392
|
+
failed: failed.length
|
|
3393
|
+
}, "sync-trust: done");
|
|
3394
|
+
return {
|
|
3395
|
+
status: failed.length > 0 ? "fail" : "ok",
|
|
3396
|
+
...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
|
|
3397
|
+
packages: results
|
|
3398
|
+
};
|
|
3399
|
+
}
|
|
3400
|
+
function parseJson(raw) {
|
|
3401
|
+
try {
|
|
3402
|
+
return JSON.parse(raw);
|
|
3403
|
+
} catch {
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
function isEotp(parsed) {
|
|
3408
|
+
return typeof parsed === "object" && parsed !== null && "error" in parsed && parsed.error?.code === "EOTP";
|
|
3409
|
+
}
|
|
3021
3410
|
async function runPublish(input = {}) {
|
|
3022
3411
|
const print = input.print ?? ((line) => console.log(line));
|
|
3023
3412
|
const logger = input.logger ?? getLogger();
|
|
@@ -3138,6 +3527,170 @@ async function runPublish(input = {}) {
|
|
|
3138
3527
|
packageNames
|
|
3139
3528
|
};
|
|
3140
3529
|
}
|
|
3530
|
+
/**
|
|
3531
|
+
* `holocron publish` (no `--initial`) — the steady-state publish
|
|
3532
|
+
* `exec.publishCmd` in `release.config.ts` should call, replacing each
|
|
3533
|
+
* repo's hand-typed `pnpm -r --filter=./packages/* publish ...` shell (which
|
|
3534
|
+
* differs, today, only in monorepo-vs-single-package, provenance on/off, and
|
|
3535
|
+
* whether a repo needs to skip already-published packages) with one uniform
|
|
3536
|
+
* invocation. Part of the config-resolution workstream, #676 — see
|
|
3537
|
+
* `.notes/tech-config-resolution.spec.md`.
|
|
3538
|
+
*
|
|
3539
|
+
* Deliberately does not do the `--initial` bootstrap dance (npm
|
|
3540
|
+
* login-if-needed, Trusted Publisher next-steps printout) — a steady-state
|
|
3541
|
+
* publish runs in CI, authenticated via OIDC automatically, no interactive
|
|
3542
|
+
* step involved. Reuses `hasPackagesDir()`'s monorepo-vs-single-package
|
|
3543
|
+
* detection rather than duplicating it.
|
|
3544
|
+
*/
|
|
3545
|
+
async function runSteadyPublish(input = {}) {
|
|
3546
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
3547
|
+
const logger = input.logger ?? getLogger();
|
|
3548
|
+
const cwd = input.cwd ?? process.cwd();
|
|
3549
|
+
const tag = input.tag ?? "latest";
|
|
3550
|
+
const dryRun = input.dryRun ?? false;
|
|
3551
|
+
const otp = input.otp;
|
|
3552
|
+
const exec = input.exec ?? defaultExec$1;
|
|
3553
|
+
const provenance = input.provenance ?? true;
|
|
3554
|
+
const skipAlreadyPublished = input.skipAlreadyPublished ?? false;
|
|
3555
|
+
const entries = input.packages ?? discoverPublicPackageEntries(cwd);
|
|
3556
|
+
const packageNames = entries.map((e) => e.name);
|
|
3557
|
+
print(`Holocron publish${dryRun ? " (dry-run)" : ""}`);
|
|
3558
|
+
print(` cwd: ${cwd}`);
|
|
3559
|
+
print(` tag: ${tag}`);
|
|
3560
|
+
print(` provenance: ${provenance}`);
|
|
3561
|
+
if (skipAlreadyPublished) print(" skip-already-published: true");
|
|
3562
|
+
logger.info({
|
|
3563
|
+
tag,
|
|
3564
|
+
provenance,
|
|
3565
|
+
skipAlreadyPublished,
|
|
3566
|
+
dryRun: dryRun || void 0
|
|
3567
|
+
}, "publish: start");
|
|
3568
|
+
if (otp) print(` otp: <${otp.length} chars>`);
|
|
3569
|
+
print("");
|
|
3570
|
+
if (entries.length === 0) {
|
|
3571
|
+
const message = "nothing to publish (no packages/* and root package.json is private or unnamed)";
|
|
3572
|
+
print(` ✗ ${message}`);
|
|
3573
|
+
logger.warn({ reason: message }, "publish: done");
|
|
3574
|
+
return {
|
|
3575
|
+
status: "fail",
|
|
3576
|
+
message,
|
|
3577
|
+
packageNames
|
|
3578
|
+
};
|
|
3579
|
+
}
|
|
3580
|
+
const baseFlags = [
|
|
3581
|
+
"--access",
|
|
3582
|
+
"public",
|
|
3583
|
+
"--no-git-checks",
|
|
3584
|
+
"--tag",
|
|
3585
|
+
tag,
|
|
3586
|
+
...provenance ? ["--provenance"] : []
|
|
3587
|
+
];
|
|
3588
|
+
const otpFlags = otp ? ["--otp", otp] : [];
|
|
3589
|
+
if (dryRun) {
|
|
3590
|
+
print("");
|
|
3591
|
+
if (skipAlreadyPublished) for (const entry of entries) print(` … would check ${entry.name}@${entry.version}, publish if not already on npm`);
|
|
3592
|
+
else {
|
|
3593
|
+
const args = [
|
|
3594
|
+
...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
|
|
3595
|
+
"publish",
|
|
3596
|
+
...baseFlags,
|
|
3597
|
+
...otpFlags
|
|
3598
|
+
];
|
|
3599
|
+
print(` … (dry-run) skipping actual publish`);
|
|
3600
|
+
print(` would run: pnpm ${args.join(" ")}`);
|
|
3601
|
+
}
|
|
3602
|
+
logger.info({
|
|
3603
|
+
tag,
|
|
3604
|
+
status: "dry-run",
|
|
3605
|
+
packages: entries.length
|
|
3606
|
+
}, "publish: done");
|
|
3607
|
+
return {
|
|
3608
|
+
status: "dry-run",
|
|
3609
|
+
message: "dry-run — no publish executed",
|
|
3610
|
+
packageNames
|
|
3611
|
+
};
|
|
3612
|
+
}
|
|
3613
|
+
if (skipAlreadyPublished) {
|
|
3614
|
+
print(" → publishing packages not already on npm…");
|
|
3615
|
+
for (const entry of entries) {
|
|
3616
|
+
const view = await exec("npm", [
|
|
3617
|
+
"view",
|
|
3618
|
+
`${entry.name}@${entry.version}`,
|
|
3619
|
+
"version"
|
|
3620
|
+
], { cwd });
|
|
3621
|
+
if (view.exitCode === 0 && view.stdout.trim()) {
|
|
3622
|
+
print(` skip ${entry.name}@${entry.version} (already published)`);
|
|
3623
|
+
continue;
|
|
3624
|
+
}
|
|
3625
|
+
const publish = await exec("pnpm", [
|
|
3626
|
+
"--filter",
|
|
3627
|
+
entry.dir,
|
|
3628
|
+
"publish",
|
|
3629
|
+
...baseFlags,
|
|
3630
|
+
...otpFlags
|
|
3631
|
+
], { cwd });
|
|
3632
|
+
if (publish.exitCode !== 0) {
|
|
3633
|
+
const message = `publish failed for ${entry.name}@${entry.version} (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
|
|
3634
|
+
print(` ✗ ${message}`);
|
|
3635
|
+
logger.warn({
|
|
3636
|
+
tag,
|
|
3637
|
+
package: entry.name,
|
|
3638
|
+
reason: message
|
|
3639
|
+
}, "publish: done");
|
|
3640
|
+
return {
|
|
3641
|
+
status: "fail",
|
|
3642
|
+
message,
|
|
3643
|
+
packageNames
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
3646
|
+
print(` ✓ ${entry.name}@${entry.version}`);
|
|
3647
|
+
}
|
|
3648
|
+
logger.info({
|
|
3649
|
+
tag,
|
|
3650
|
+
status: "ok",
|
|
3651
|
+
packages: entries.length
|
|
3652
|
+
}, "publish: done");
|
|
3653
|
+
return {
|
|
3654
|
+
status: "ok",
|
|
3655
|
+
packageNames
|
|
3656
|
+
};
|
|
3657
|
+
}
|
|
3658
|
+
print(" → publishing all public @theholocron/* packages…");
|
|
3659
|
+
const publish = await exec("pnpm", [
|
|
3660
|
+
...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
|
|
3661
|
+
"publish",
|
|
3662
|
+
...baseFlags,
|
|
3663
|
+
...otpFlags
|
|
3664
|
+
], { cwd });
|
|
3665
|
+
if (publish.exitCode !== 0) {
|
|
3666
|
+
const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
|
|
3667
|
+
print(` ✗ ${message}`);
|
|
3668
|
+
if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
|
|
3669
|
+
print("");
|
|
3670
|
+
print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
|
|
3671
|
+
print(` pnpm exec holocron publish --otp <6-digit-code>`);
|
|
3672
|
+
}
|
|
3673
|
+
logger.warn({
|
|
3674
|
+
tag,
|
|
3675
|
+
reason: message
|
|
3676
|
+
}, "publish: done");
|
|
3677
|
+
return {
|
|
3678
|
+
status: "fail",
|
|
3679
|
+
message,
|
|
3680
|
+
packageNames
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
print(" ✓ publish complete");
|
|
3684
|
+
logger.info({
|
|
3685
|
+
tag,
|
|
3686
|
+
status: "ok",
|
|
3687
|
+
packages: entries.length
|
|
3688
|
+
}, "publish: done");
|
|
3689
|
+
return {
|
|
3690
|
+
status: "ok",
|
|
3691
|
+
packageNames
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3141
3694
|
function hasPackagesDir(cwd) {
|
|
3142
3695
|
return existsSync(join(cwd, "packages"));
|
|
3143
3696
|
}
|
|
@@ -3151,6 +3704,37 @@ function publicPackageName(pkgPath) {
|
|
|
3151
3704
|
return;
|
|
3152
3705
|
}
|
|
3153
3706
|
}
|
|
3707
|
+
/** Read a `package.json`'s `name` + `version`, when it's public (`!private && name`). */
|
|
3708
|
+
function publicPackageEntry(pkgPath, dir) {
|
|
3709
|
+
if (!existsSync(pkgPath)) return void 0;
|
|
3710
|
+
try {
|
|
3711
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
3712
|
+
return !pkg.private && pkg.name && pkg.version ? {
|
|
3713
|
+
name: pkg.name,
|
|
3714
|
+
version: pkg.version,
|
|
3715
|
+
dir
|
|
3716
|
+
} : void 0;
|
|
3717
|
+
} catch {
|
|
3718
|
+
return;
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Same repo-layout detection as {@link discoverPublicPackages}, but carrying
|
|
3723
|
+
* version + a `pnpm --filter`-ready path — {@link runSteadyPublish}'s
|
|
3724
|
+
* `skipAlreadyPublished` mode needs both to check `npm view <pkg>@<version>`
|
|
3725
|
+
* and publish one workspace at a time.
|
|
3726
|
+
*/
|
|
3727
|
+
function discoverPublicPackageEntries(cwd) {
|
|
3728
|
+
if (!hasPackagesDir(cwd)) {
|
|
3729
|
+
const entry = publicPackageEntry(join(cwd, "package.json"), ".");
|
|
3730
|
+
return entry ? [entry] : [];
|
|
3731
|
+
}
|
|
3732
|
+
const packagesDir = join(cwd, "packages");
|
|
3733
|
+
return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
|
|
3734
|
+
const entry = publicPackageEntry(join(packagesDir, e.name, "package.json"), `./packages/${e.name}`);
|
|
3735
|
+
return entry ? [entry] : [];
|
|
3736
|
+
});
|
|
3737
|
+
}
|
|
3154
3738
|
/**
|
|
3155
3739
|
* Monorepo (`packages/` present): every public `packages/*` workspace.
|
|
3156
3740
|
* Single package (no `packages/`): the root `package.json`, if it's public —
|
|
@@ -3180,7 +3764,7 @@ function printNextSteps(print, env, packageNames, repoName) {
|
|
|
3180
3764
|
print("");
|
|
3181
3765
|
print(" → next: configure Trusted Publisher for each package on npm:");
|
|
3182
3766
|
for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
|
|
3183
|
-
print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow:
|
|
3767
|
+
print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
|
|
3184
3768
|
if (env.get("NPM_TOKEN")) {
|
|
3185
3769
|
print("");
|
|
3186
3770
|
print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
|
|
@@ -3315,10 +3899,13 @@ async function runSecretsSync(input) {
|
|
|
3315
3899
|
const rows = [];
|
|
3316
3900
|
if (loader.has("secrets")) {
|
|
3317
3901
|
const secrets = loader.get("secrets");
|
|
3318
|
-
|
|
3902
|
+
const scope = input.githubSecretScope ?? { kind: "repo" };
|
|
3903
|
+
const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
|
|
3904
|
+
print(style.step("secrets"));
|
|
3319
3905
|
for (const key of keys) {
|
|
3320
|
-
|
|
3321
|
-
|
|
3906
|
+
if (allowedKeys && !allowedKeys.has(key)) continue;
|
|
3907
|
+
rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
|
|
3908
|
+
await secrets.setSecret(scope, key, envVars[key]);
|
|
3322
3909
|
}));
|
|
3323
3910
|
print(formatRow(rows[rows.length - 1]));
|
|
3324
3911
|
}
|
|
@@ -3410,11 +3997,114 @@ function formatRow(row) {
|
|
|
3410
3997
|
if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
|
|
3411
3998
|
return ` ${style.dim(`· ${label}`)}`;
|
|
3412
3999
|
}
|
|
4000
|
+
function scopeLabel(scope) {
|
|
4001
|
+
if (scope.kind === "repo") return "scope=repo";
|
|
4002
|
+
if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
|
|
4003
|
+
return `scope=environment,env=${scope.name}`;
|
|
4004
|
+
}
|
|
3413
4005
|
function vaultProviderName(loader) {
|
|
3414
4006
|
if (!loader.has("vault")) return "<missing>";
|
|
3415
4007
|
return loader.get("vault").providerName;
|
|
3416
4008
|
}
|
|
3417
4009
|
//#endregion
|
|
4010
|
+
//#region src/commands/setup/derived-properties.ts
|
|
4011
|
+
/**
|
|
4012
|
+
* Repo archetype, in priority order:
|
|
4013
|
+
* 1. `*-template` repo name → `template` (independent of package shape).
|
|
4014
|
+
* 2. Monorepo whose primary published artifact is a CLI (some workspace
|
|
4015
|
+
* package has `bin`) → `platform` — matches `holocron` itself: the CLI is
|
|
4016
|
+
* the deliverable, the plugin packages are supporting infrastructure for
|
|
4017
|
+
* it, not separate products.
|
|
4018
|
+
* 3. Monorepo with no CLI but at least one non-private workspace package →
|
|
4019
|
+
* `library` — matches `clients`/`configs`/`utils`/`themes`/
|
|
4020
|
+
* `observability`: a family of published packages, none of them a CLI.
|
|
4021
|
+
* Checked *before* falling into the root-package branch below, because
|
|
4022
|
+
* these repos' own root `package.json` is private and often carries
|
|
4023
|
+
* `@theholocron/astro-config` for a docs site built from the monorepo
|
|
4024
|
+
* root — without this check they'd wrongly resolve to `docs` off that
|
|
4025
|
+
* root-level signal alone, when the docs site is a secondary concern of
|
|
4026
|
+
* a library collection, not what the repo fundamentally is.
|
|
4027
|
+
* 4. Single-package repo with `package.json#bin` → `cli`.
|
|
4028
|
+
* 5. Docs-only site (astro/starlight present, root package private, no
|
|
4029
|
+
* publishable exports) → `docs`.
|
|
4030
|
+
* 6. Private, non-publishable, non-docs → `app`.
|
|
4031
|
+
* 7. Otherwise, a single publishable package → `library`.
|
|
4032
|
+
*/
|
|
4033
|
+
function deriveProfile(input) {
|
|
4034
|
+
if (/-template$/.test(input.repoName)) return "template";
|
|
4035
|
+
if (input.isMonorepo) {
|
|
4036
|
+
if (input.workspacePackageJsons.some((pkg) => Boolean(pkg.bin))) return "platform";
|
|
4037
|
+
if (input.workspacePackageJsons.some((pkg) => pkg.private !== true)) return "library";
|
|
4038
|
+
}
|
|
4039
|
+
const pkg = input.rootPackageJson;
|
|
4040
|
+
if (pkg?.bin) return "cli";
|
|
4041
|
+
const deps = {
|
|
4042
|
+
...pkg?.dependencies,
|
|
4043
|
+
...pkg?.devDependencies
|
|
4044
|
+
};
|
|
4045
|
+
const isDocsSite = Boolean(deps["@theholocron/astro-config"] || deps["astro"]);
|
|
4046
|
+
if (pkg?.private && isDocsSite) return "docs";
|
|
4047
|
+
if (pkg?.private) return "app";
|
|
4048
|
+
return "library";
|
|
4049
|
+
}
|
|
4050
|
+
const KNOWN_STACK_DEPS = [
|
|
4051
|
+
"next",
|
|
4052
|
+
"vite",
|
|
4053
|
+
"astro",
|
|
4054
|
+
"tsdown",
|
|
4055
|
+
"rollup",
|
|
4056
|
+
"webpack",
|
|
4057
|
+
"storybook",
|
|
4058
|
+
"vitest",
|
|
4059
|
+
"playwright",
|
|
4060
|
+
"turbo"
|
|
4061
|
+
];
|
|
4062
|
+
/**
|
|
4063
|
+
* Detected build/framework tooling — the literal "does this repo need
|
|
4064
|
+
* next/vite/tsdown" signal `holocron.config.ts` has no field for today
|
|
4065
|
+
* (framework choice isn't a config concept, unlike `providers`/`tasks`).
|
|
4066
|
+
* Curated table, not exhaustive by design — add to `KNOWN_STACK_DEPS` as new
|
|
4067
|
+
* frameworks show up rather than trying to detect anything on npm.
|
|
4068
|
+
*/
|
|
4069
|
+
function deriveStack(pkg) {
|
|
4070
|
+
const deps = {
|
|
4071
|
+
...pkg?.dependencies,
|
|
4072
|
+
...pkg?.devDependencies
|
|
4073
|
+
};
|
|
4074
|
+
return KNOWN_STACK_DEPS.filter((name) => name in deps);
|
|
4075
|
+
}
|
|
4076
|
+
/** The provider capability keys actually wired in `providers: {}` — zero heuristics. */
|
|
4077
|
+
function deriveCapabilities(providers) {
|
|
4078
|
+
return Object.keys(providers ?? {}).sort();
|
|
4079
|
+
}
|
|
4080
|
+
/**
|
|
4081
|
+
* `source` + `ci` are the minimal baseline every repo `holocron setup`
|
|
4082
|
+
* touches wires — every `holocron.config.ts` in this org already satisfies
|
|
4083
|
+
* this, so `non-compliant` here means drift (a provider manually removed
|
|
4084
|
+
* after setup), not an unmet aspirational policy. Deliberately not a richer
|
|
4085
|
+
* per-profile policy yet (e.g. "a `library` must have `deployment`") — that's
|
|
4086
|
+
* a Phase B (GitHub App) refinement once it can post *why* on a check run.
|
|
4087
|
+
*/
|
|
4088
|
+
const REQUIRED_BASELINE = ["source", "ci"];
|
|
4089
|
+
function deriveCompliance(capabilities) {
|
|
4090
|
+
return REQUIRED_BASELINE.every((key) => capabilities.includes(key)) ? "compliant" : "non-compliant";
|
|
4091
|
+
}
|
|
4092
|
+
/**
|
|
4093
|
+
* Reads each workspace package's actual `package.json` content (not just
|
|
4094
|
+
* `readWorkspacePackages()`'s `{ slug, name, dir }` — `deriveProfile()`'s
|
|
4095
|
+
* `platform` detection needs `bin`, which that helper doesn't carry).
|
|
4096
|
+
* Missing/invalid files are skipped, matching `readWorkspacePackages()`'s
|
|
4097
|
+
* own soft-fail behavior.
|
|
4098
|
+
*/
|
|
4099
|
+
async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
|
|
4100
|
+
const results = [];
|
|
4101
|
+
for (const { slug, dir = "packages" } of workspacePackages) try {
|
|
4102
|
+
const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
|
|
4103
|
+
results.push(JSON.parse(raw));
|
|
4104
|
+
} catch {}
|
|
4105
|
+
return results;
|
|
4106
|
+
}
|
|
4107
|
+
//#endregion
|
|
3418
4108
|
//#region src/commands/setup/labels.ts
|
|
3419
4109
|
const CANONICAL_LABELS = [
|
|
3420
4110
|
{
|
|
@@ -3554,6 +4244,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
|
|
|
3554
4244
|
//#region src/templates/configs/alexjs/alexrc.json
|
|
3555
4245
|
var alexrc_default = { allow: [
|
|
3556
4246
|
"dead",
|
|
4247
|
+
"execute",
|
|
4248
|
+
"execution",
|
|
4249
|
+
"executes",
|
|
3557
4250
|
"failure",
|
|
3558
4251
|
"failures",
|
|
3559
4252
|
"hook",
|
|
@@ -3563,12 +4256,30 @@ var alexrc_default = { allow: [
|
|
|
3563
4256
|
] };
|
|
3564
4257
|
//#endregion
|
|
3565
4258
|
//#region src/templates/configs/alexjs/create-config.ts
|
|
3566
|
-
const { workflowHeader: workflowHeader$
|
|
4259
|
+
const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
|
|
3567
4260
|
function createRcConfig() {
|
|
3568
4261
|
return JSON.stringify(alexrc_default, null, 2) + "\n";
|
|
3569
4262
|
}
|
|
3570
4263
|
function createIgnoreConfig$1() {
|
|
3571
|
-
return `${workflowHeader$
|
|
4264
|
+
return `${workflowHeader$9()}${alexignore_default}`;
|
|
4265
|
+
}
|
|
4266
|
+
//#endregion
|
|
4267
|
+
//#region src/templates/configs/commit-msg/commit-msg
|
|
4268
|
+
var commit_msg_default = "__HOLOCRON_SCRIPT__ lint commit-msg \"$1\"\n";
|
|
4269
|
+
//#endregion
|
|
4270
|
+
//#region src/templates/configs/commit-msg/create-config.ts
|
|
4271
|
+
const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
|
|
4272
|
+
/**
|
|
4273
|
+
* `.husky/commit-msg` — lints the commit message via `holocron lint
|
|
4274
|
+
* commit-msg` (holocron#789), the real programmatic replacement for
|
|
4275
|
+
* shelling out to `commitlint --edit`. Installed by `holocron setup` when
|
|
4276
|
+
* hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
|
|
4277
|
+
*
|
|
4278
|
+
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4279
|
+
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4280
|
+
*/
|
|
4281
|
+
function createConfig$7(holocronScript = "pnpm exec holocron") {
|
|
4282
|
+
return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
3572
4283
|
}
|
|
3573
4284
|
//#endregion
|
|
3574
4285
|
//#region src/templates/configs/devmoji/create-config.ts
|
|
@@ -3584,7 +4295,7 @@ function createConfig$6() {
|
|
|
3584
4295
|
}
|
|
3585
4296
|
//#endregion
|
|
3586
4297
|
//#region src/templates/configs/editorconfig/editorconfig
|
|
3587
|
-
var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
|
|
4298
|
+
var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\nindent_style = unset\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
|
|
3588
4299
|
//#endregion
|
|
3589
4300
|
//#region src/templates/configs/editorconfig/create-config.ts
|
|
3590
4301
|
const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
|
|
@@ -3673,7 +4384,7 @@ function createConfig$2(holocronScript = "pnpm exec holocron") {
|
|
|
3673
4384
|
}
|
|
3674
4385
|
//#endregion
|
|
3675
4386
|
//#region src/templates/configs/prepare-commit-msg/prepare-commit-msg
|
|
3676
|
-
var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\
|
|
4387
|
+
var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\n# --config <resolved shared path> (config-resolution workstream, #676) — falls\n# back to devmoji's own auto-discovery of a local devmoji.config.cjs when the\n# shared package's built dist/ isn't present (not installed, or a stale\n# install predating this file).\nDEVMOJI_CONFIG=\"node_modules/@theholocron/devmoji-config/dist/index.cjs\"\nif [ -f \"$DEVMOJI_CONFIG\" ]; then\n npx devmoji --config \"$DEVMOJI_CONFIG\" -e\nelse\n npx devmoji -e\nfi\n";
|
|
3677
4388
|
//#endregion
|
|
3678
4389
|
//#region src/templates/configs/prepare-commit-msg/create-config.ts
|
|
3679
4390
|
const { workflowHeader: workflowHeader$3 } = createHeader({ source: "packages/cli/src/templates/configs/prepare-commit-msg/create-config.ts" });
|
|
@@ -4518,6 +5229,10 @@ const { workflowHeader: workflowHeader$1 } = createHeader({
|
|
|
4518
5229
|
source: "packages/cli/src/commands/setup/run-setup.ts",
|
|
4519
5230
|
tool: "holocron setup"
|
|
4520
5231
|
});
|
|
5232
|
+
const { workflowHeader: thinCallerHeader } = createHeader({
|
|
5233
|
+
source: "packages/astromech/src/thin-callers.ts",
|
|
5234
|
+
tool: "holocron setup"
|
|
5235
|
+
});
|
|
4521
5236
|
async function runSetup(input) {
|
|
4522
5237
|
const print = input.print ?? ((line) => console.log(line));
|
|
4523
5238
|
const logger = getLogger();
|
|
@@ -4552,7 +5267,7 @@ async function runSetup(input) {
|
|
|
4552
5267
|
}, { skipCodes: SECURITY_SKIP_CODES[method] }));
|
|
4553
5268
|
print(formatStep(steps[steps.length - 1]));
|
|
4554
5269
|
}
|
|
4555
|
-
const usesAdvancedCodeQL = (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("
|
|
5270
|
+
const usesAdvancedCodeQL = (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("security.codeScanning");
|
|
4556
5271
|
steps.push(await runStep("source", usesAdvancedCodeQL ? "disableDefaultCodeScanning" : "enableCodeScanning", dryRun, async () => {
|
|
4557
5272
|
if (usesAdvancedCodeQL) await source.disableDefaultCodeScanning();
|
|
4558
5273
|
else return await source.enableCodeScanning();
|
|
@@ -4560,7 +5275,10 @@ async function runSetup(input) {
|
|
|
4560
5275
|
print(formatStep(steps[steps.length - 1]));
|
|
4561
5276
|
if (effectivePreset && effectivePreset !== "none") {
|
|
4562
5277
|
steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
|
|
4563
|
-
await source.updateRepoSettings(
|
|
5278
|
+
await source.updateRepoSettings({
|
|
5279
|
+
...BALANCED_REPO_SETTINGS,
|
|
5280
|
+
...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
|
|
5281
|
+
});
|
|
4564
5282
|
}));
|
|
4565
5283
|
print(formatStep(steps[steps.length - 1]));
|
|
4566
5284
|
const tasksConfig = { tasks: config.tasks };
|
|
@@ -4589,7 +5307,8 @@ async function runSetup(input) {
|
|
|
4589
5307
|
}).thinCallers();
|
|
4590
5308
|
for (const entry of tasks) {
|
|
4591
5309
|
const name = typeof entry === "string" ? entry : entry.name;
|
|
4592
|
-
|
|
5310
|
+
const isCombinedContentOnly = name === "knowledge.docs" || name === "knowledge.components";
|
|
5311
|
+
if (!KNOWN_WORKFLOWS.has(name) && !isCombinedContentOnly) {
|
|
4593
5312
|
steps.push({
|
|
4594
5313
|
capability: "source",
|
|
4595
5314
|
step: `write workflow ${name}`,
|
|
@@ -4599,12 +5318,12 @@ async function runSetup(input) {
|
|
|
4599
5318
|
print(formatStep(steps[steps.length - 1]));
|
|
4600
5319
|
continue;
|
|
4601
5320
|
}
|
|
4602
|
-
const filename =
|
|
5321
|
+
const filename = `${name}.yml`;
|
|
4603
5322
|
const content = files.get(filename);
|
|
4604
5323
|
if (content === void 0) continue;
|
|
4605
|
-
const step =
|
|
5324
|
+
const step = content.includes("workflows/preview.yml@main") ? `write workflow ${name} (with preview)` : `write workflow ${name}`;
|
|
4606
5325
|
steps.push(await runStep("source", step, dryRun, async () => {
|
|
4607
|
-
await source.writeWorkflowFile(filename, `${
|
|
5326
|
+
await source.writeWorkflowFile(filename, `${thinCallerHeader()}${content}`);
|
|
4608
5327
|
}));
|
|
4609
5328
|
print(formatStep(steps[steps.length - 1]));
|
|
4610
5329
|
}
|
|
@@ -4665,6 +5384,10 @@ async function runSetup(input) {
|
|
|
4665
5384
|
await source.writeRepoFile(".husky/prepare-commit-msg", createConfig$1());
|
|
4666
5385
|
}));
|
|
4667
5386
|
print(formatStep(steps[steps.length - 1]));
|
|
5387
|
+
steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
|
|
5388
|
+
await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
|
|
5389
|
+
}));
|
|
5390
|
+
print(formatStep(steps[steps.length - 1]));
|
|
4668
5391
|
if (hooksEnabled) {
|
|
4669
5392
|
steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
|
|
4670
5393
|
await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
|
|
@@ -4699,7 +5422,7 @@ async function runSetup(input) {
|
|
|
4699
5422
|
print(formatStep(steps[steps.length - 1]));
|
|
4700
5423
|
}
|
|
4701
5424
|
{
|
|
4702
|
-
const hasTestWorkflow = (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("
|
|
5425
|
+
const hasTestWorkflow = (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("verification.unitTests");
|
|
4703
5426
|
const existing = await readFile(join(input.context.repoRoot, "codecov.yml"), "utf8").catch(() => null);
|
|
4704
5427
|
if (!hasTestWorkflow && existing == null) steps.push({
|
|
4705
5428
|
capability: "source",
|
|
@@ -4718,6 +5441,41 @@ async function runSetup(input) {
|
|
|
4718
5441
|
}));
|
|
4719
5442
|
print(formatStep(steps[steps.length - 1]));
|
|
4720
5443
|
}
|
|
5444
|
+
{
|
|
5445
|
+
const astromechTurbo = createAstromech({
|
|
5446
|
+
cwd: input.context.repoRoot,
|
|
5447
|
+
config: { tasks: config.tasks },
|
|
5448
|
+
logger
|
|
5449
|
+
});
|
|
5450
|
+
const content = astromechTurbo.turboConfig();
|
|
5451
|
+
if (content === null) steps.push({
|
|
5452
|
+
capability: "source",
|
|
5453
|
+
step: "write turbo.json",
|
|
5454
|
+
status: "skip",
|
|
5455
|
+
message: "no fan-out-eligible tasks configured"
|
|
5456
|
+
});
|
|
5457
|
+
else {
|
|
5458
|
+
steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
|
|
5459
|
+
await source.writeRepoFile("turbo.json", content);
|
|
5460
|
+
}));
|
|
5461
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5462
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
5463
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
5464
|
+
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
5465
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
5466
|
+
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
5467
|
+
if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
5468
|
+
await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
|
|
5469
|
+
}));
|
|
5470
|
+
if (rootPkgRaw) {
|
|
5471
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
5472
|
+
if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
|
|
5473
|
+
await source.writeRepoFile("package.json", depFix.content);
|
|
5474
|
+
}));
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5478
|
+
}
|
|
4721
5479
|
if (source.syncLabels) {
|
|
4722
5480
|
steps.push(await runStep("source", "sync labels", dryRun, async () => {
|
|
4723
5481
|
return source.syncLabels(CANONICAL_LABELS, STALE_LABELS);
|
|
@@ -4725,7 +5483,7 @@ async function runSetup(input) {
|
|
|
4725
5483
|
print(formatStep(steps[steps.length - 1]));
|
|
4726
5484
|
}
|
|
4727
5485
|
const properties = {};
|
|
4728
|
-
if (effectivePreset && effectivePreset !== "none") properties["
|
|
5486
|
+
if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
|
|
4729
5487
|
const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
|
|
4730
5488
|
properties["monorepo"] = String(isMonorepo);
|
|
4731
5489
|
const manual = repo?.properties ?? {};
|
|
@@ -4733,6 +5491,21 @@ async function runSetup(input) {
|
|
|
4733
5491
|
if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
|
|
4734
5492
|
if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
|
|
4735
5493
|
if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
|
|
5494
|
+
{
|
|
5495
|
+
const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
5496
|
+
const workspacePackages = readWorkspacePackages(input.context.repoRoot);
|
|
5497
|
+
const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
|
|
5498
|
+
properties["holocron_profile"] = deriveProfile({
|
|
5499
|
+
rootPackageJson,
|
|
5500
|
+
repoName: basename(input.context.repoRoot),
|
|
5501
|
+
isMonorepo,
|
|
5502
|
+
workspacePackageJsons
|
|
5503
|
+
});
|
|
5504
|
+
properties["holocron_stack"] = deriveStack(rootPackageJson);
|
|
5505
|
+
const capabilities = deriveCapabilities(config.providers);
|
|
5506
|
+
properties["holocron_capabilities"] = capabilities;
|
|
5507
|
+
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
5508
|
+
}
|
|
4736
5509
|
if (source.syncProperties) {
|
|
4737
5510
|
steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
|
|
4738
5511
|
print(formatStep(steps[steps.length - 1]));
|
|
@@ -4751,34 +5524,36 @@ async function runSetup(input) {
|
|
|
4751
5524
|
print(formatStep(steps[steps.length - 1]));
|
|
4752
5525
|
}
|
|
4753
5526
|
const teams = repo?.teams ?? [];
|
|
4754
|
-
if (teams.length > 0)
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
5527
|
+
if (teams.length > 0) {
|
|
5528
|
+
if (source.syncTeams) {
|
|
5529
|
+
steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
|
|
5530
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5531
|
+
const repoCoord = input.context.repo ?? repo?.name ?? "";
|
|
5532
|
+
const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
|
|
5533
|
+
const writeableTeams = teams.map((t) => typeof t === "string" ? {
|
|
5534
|
+
slug: t,
|
|
5535
|
+
permission: "push"
|
|
5536
|
+
} : t).filter((t) => [
|
|
5537
|
+
"push",
|
|
5538
|
+
"maintain",
|
|
5539
|
+
"admin"
|
|
5540
|
+
].includes(t.permission));
|
|
5541
|
+
if (org && writeableTeams.length > 0) {
|
|
5542
|
+
steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
|
|
5543
|
+
const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
|
|
5544
|
+
await source.writeRepoFile(".github/CODEOWNERS", content);
|
|
5545
|
+
}));
|
|
5546
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5547
|
+
}
|
|
5548
|
+
} else {
|
|
5549
|
+
steps.push({
|
|
5550
|
+
capability: "source",
|
|
5551
|
+
step: "sync teams",
|
|
5552
|
+
status: "skip",
|
|
5553
|
+
message: "provider does not implement syncTeams"
|
|
5554
|
+
});
|
|
4772
5555
|
print(formatStep(steps[steps.length - 1]));
|
|
4773
5556
|
}
|
|
4774
|
-
} else {
|
|
4775
|
-
steps.push({
|
|
4776
|
-
capability: "source",
|
|
4777
|
-
step: "sync teams",
|
|
4778
|
-
status: "skip",
|
|
4779
|
-
message: "provider does not implement syncTeams"
|
|
4780
|
-
});
|
|
4781
|
-
print(formatStep(steps[steps.length - 1]));
|
|
4782
5557
|
}
|
|
4783
5558
|
}
|
|
4784
5559
|
if (loader.has("environments")) {
|
|
@@ -4834,11 +5609,9 @@ async function runSetup(input) {
|
|
|
4834
5609
|
const wikiProxy = wiki.proxyConfig?.();
|
|
4835
5610
|
if (wikiDns && loader.has("dns")) {
|
|
4836
5611
|
const dns = loader.get("dns");
|
|
4837
|
-
steps.push(await runStep("dns", `upsertRecord ${wikiDns.
|
|
5612
|
+
steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
|
|
4838
5613
|
await dns.upsertRecord(wikiDns.zone, {
|
|
4839
|
-
|
|
4840
|
-
name: wikiDns.cname,
|
|
4841
|
-
content: wikiDns.target,
|
|
5614
|
+
...wikiDns.record,
|
|
4842
5615
|
ttl: 1,
|
|
4843
5616
|
...wikiProxy ? { proxied: true } : {}
|
|
4844
5617
|
});
|
|
@@ -4847,8 +5620,8 @@ async function runSetup(input) {
|
|
|
4847
5620
|
}
|
|
4848
5621
|
if (wikiProxy && wikiDns && loader.has("workers")) {
|
|
4849
5622
|
const workers = loader.get("workers");
|
|
4850
|
-
steps.push(await runStep("workers", `upsertProxy ${wikiDns.
|
|
4851
|
-
await workers.upsertProxy(wikiDns.
|
|
5623
|
+
steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
|
|
5624
|
+
await workers.upsertProxy(wikiDns.record.name, wikiProxy);
|
|
4852
5625
|
}));
|
|
4853
5626
|
print(formatStep(steps[steps.length - 1]));
|
|
4854
5627
|
}
|
|
@@ -4856,16 +5629,36 @@ async function runSetup(input) {
|
|
|
4856
5629
|
if (loader.has("deployment")) {
|
|
4857
5630
|
const deploy = loader.get("deployment");
|
|
4858
5631
|
print(style.step("deployment"));
|
|
4859
|
-
const deployEntry = (config.tasks ?? []).map((e) => typeof e === "string" ? { name: e } : e).find((e) => e.name === "deploy");
|
|
5632
|
+
const deployEntry = (config.tasks ?? []).map((e) => typeof e === "string" ? { name: e } : e).find((e) => e.name === "delivery.deploy" || e.name === "knowledge.docs" || e.name === "knowledge.components");
|
|
4860
5633
|
const previewCfg = deployEntry?.with ? extractPreviewConfig(deployEntry.with, {
|
|
4861
5634
|
org: config.org,
|
|
4862
5635
|
domain: config.domain
|
|
4863
5636
|
}) : null;
|
|
5637
|
+
const ensureCustomDomain = async (projectId, hostname) => {
|
|
5638
|
+
if (!deploy.ensureCustomDomain) return;
|
|
5639
|
+
const customDomainDns = { value: null };
|
|
5640
|
+
steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
|
|
5641
|
+
customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
|
|
5642
|
+
}));
|
|
5643
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5644
|
+
if (customDomainDns.value && loader.has("dns")) {
|
|
5645
|
+
const dns = loader.get("dns");
|
|
5646
|
+
const dnsRecord = customDomainDns.value;
|
|
5647
|
+
steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
|
|
5648
|
+
await dns.upsertRecord(dnsRecord.zone, {
|
|
5649
|
+
...dnsRecord.record,
|
|
5650
|
+
ttl: 1
|
|
5651
|
+
});
|
|
5652
|
+
}));
|
|
5653
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5654
|
+
}
|
|
5655
|
+
};
|
|
4864
5656
|
if (!previewCfg) {
|
|
4865
5657
|
steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
|
|
4866
5658
|
await deploy.ensureProject({ name: config.name });
|
|
4867
5659
|
}));
|
|
4868
5660
|
print(formatStep(steps[steps.length - 1]));
|
|
5661
|
+
if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
|
|
4869
5662
|
}
|
|
4870
5663
|
if (previewCfg) {
|
|
4871
5664
|
steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
|
|
@@ -4873,13 +5666,8 @@ async function runSetup(input) {
|
|
|
4873
5666
|
}));
|
|
4874
5667
|
print(formatStep(steps[steps.length - 1]));
|
|
4875
5668
|
}
|
|
4876
|
-
if (previewCfg?.domain
|
|
4877
|
-
|
|
4878
|
-
await deploy.ensureCustomDomain(previewCfg.project, previewCfg.domain);
|
|
4879
|
-
}));
|
|
4880
|
-
print(formatStep(steps[steps.length - 1]));
|
|
4881
|
-
}
|
|
4882
|
-
if (previewCfg?.domain && loader.has("dns")) {
|
|
5669
|
+
if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
|
|
5670
|
+
if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
|
|
4883
5671
|
const dns = loader.get("dns");
|
|
4884
5672
|
const wildcardDomain = `*.${previewCfg.domain}`;
|
|
4885
5673
|
steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
|
|
@@ -5327,11 +6115,13 @@ async function runSyncReadme(input) {
|
|
|
5327
6115
|
}
|
|
5328
6116
|
const description = loaded.resolved.description ?? "";
|
|
5329
6117
|
const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
|
|
6118
|
+
const scripts = pkg.scripts ?? {};
|
|
6119
|
+
const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
|
|
5330
6120
|
const sections = generateReadme({
|
|
5331
6121
|
description,
|
|
5332
6122
|
homepage,
|
|
5333
|
-
scripts
|
|
5334
|
-
packages
|
|
6123
|
+
scripts,
|
|
6124
|
+
packages
|
|
5335
6125
|
});
|
|
5336
6126
|
const markerSections = {
|
|
5337
6127
|
...sections.packages !== void 0 && { packages: sections.packages },
|
|
@@ -5573,14 +6363,14 @@ async function runSyncWiki(input) {
|
|
|
5573
6363
|
capability: "local",
|
|
5574
6364
|
step: "sync wiki",
|
|
5575
6365
|
status: "fail",
|
|
5576
|
-
message: err instanceof Error ? err.message :
|
|
6366
|
+
message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
|
|
5577
6367
|
};
|
|
5578
6368
|
}
|
|
5579
6369
|
}
|
|
5580
6370
|
//#endregion
|
|
5581
6371
|
//#region src/commands/sync.ts
|
|
5582
6372
|
const { workflowHeader } = createHeader({
|
|
5583
|
-
source: "packages/
|
|
6373
|
+
source: "packages/astromech/src/thin-callers.ts",
|
|
5584
6374
|
tool: "holocron sync"
|
|
5585
6375
|
});
|
|
5586
6376
|
const SYNC_STEPS = [
|
|
@@ -5594,6 +6384,7 @@ const SYNC_STEPS = [
|
|
|
5594
6384
|
"readme",
|
|
5595
6385
|
"workflows",
|
|
5596
6386
|
"scripts",
|
|
6387
|
+
"turbo",
|
|
5597
6388
|
"wiki"
|
|
5598
6389
|
];
|
|
5599
6390
|
const LOCAL_STEPS = /* @__PURE__ */ new Set([
|
|
@@ -5603,6 +6394,7 @@ const LOCAL_STEPS = /* @__PURE__ */ new Set([
|
|
|
5603
6394
|
"readme",
|
|
5604
6395
|
"workflows",
|
|
5605
6396
|
"scripts",
|
|
6397
|
+
"turbo",
|
|
5606
6398
|
"wiki"
|
|
5607
6399
|
]);
|
|
5608
6400
|
async function runSync(input) {
|
|
@@ -5633,40 +6425,59 @@ async function runSync(input) {
|
|
|
5633
6425
|
for (const stepName of SYNC_STEPS) {
|
|
5634
6426
|
if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
|
|
5635
6427
|
if (LOCAL_STEPS.has(stepName)) continue;
|
|
5636
|
-
if (stepName === "labels")
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
6428
|
+
if (stepName === "labels") {
|
|
6429
|
+
if (source.syncLabels) {
|
|
6430
|
+
steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
|
|
6431
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6432
|
+
} else {
|
|
6433
|
+
steps.push({
|
|
6434
|
+
capability: "source",
|
|
6435
|
+
step: "sync labels",
|
|
6436
|
+
status: "skip",
|
|
6437
|
+
message: "provider does not implement syncLabels"
|
|
6438
|
+
});
|
|
6439
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6440
|
+
}
|
|
5647
6441
|
}
|
|
5648
|
-
if (stepName === "properties")
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
6442
|
+
if (stepName === "properties") {
|
|
6443
|
+
if (source.syncProperties) {
|
|
6444
|
+
const repo = config.repo;
|
|
6445
|
+
const properties = {};
|
|
6446
|
+
const effectivePreset = repo?.protection;
|
|
6447
|
+
if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
|
|
6448
|
+
const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
|
|
6449
|
+
properties["monorepo"] = String(isMonorepo);
|
|
6450
|
+
const manual = repo?.properties ?? {};
|
|
6451
|
+
if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
|
|
6452
|
+
if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
|
|
6453
|
+
if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
|
|
6454
|
+
if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
|
|
6455
|
+
{
|
|
6456
|
+
const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
6457
|
+
const workspacePackages = readWorkspacePackages(input.context.repoRoot);
|
|
6458
|
+
const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
|
|
6459
|
+
properties["holocron_profile"] = deriveProfile({
|
|
6460
|
+
rootPackageJson,
|
|
6461
|
+
repoName: basename(input.context.repoRoot),
|
|
6462
|
+
isMonorepo,
|
|
6463
|
+
workspacePackageJsons
|
|
6464
|
+
});
|
|
6465
|
+
properties["holocron_stack"] = deriveStack(rootPackageJson);
|
|
6466
|
+
const capabilities = deriveCapabilities(config.providers);
|
|
6467
|
+
properties["holocron_capabilities"] = capabilities;
|
|
6468
|
+
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
6469
|
+
}
|
|
6470
|
+
steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
|
|
6471
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6472
|
+
} else {
|
|
6473
|
+
steps.push({
|
|
6474
|
+
capability: "source",
|
|
6475
|
+
step: "sync properties",
|
|
6476
|
+
status: "skip",
|
|
6477
|
+
message: "provider does not implement syncProperties"
|
|
6478
|
+
});
|
|
6479
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6480
|
+
}
|
|
5670
6481
|
}
|
|
5671
6482
|
if (stepName === "teams") {
|
|
5672
6483
|
const teams = config.repo?.teams ?? [];
|
|
@@ -5751,6 +6562,7 @@ async function runSync(input) {
|
|
|
5751
6562
|
"readme",
|
|
5752
6563
|
"workflows",
|
|
5753
6564
|
"scripts",
|
|
6565
|
+
"turbo",
|
|
5754
6566
|
"wiki"
|
|
5755
6567
|
]) {
|
|
5756
6568
|
if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
|
|
@@ -5855,7 +6667,8 @@ async function runSync(input) {
|
|
|
5855
6667
|
}).thinCallers();
|
|
5856
6668
|
for (const entry of taskEntries) {
|
|
5857
6669
|
const name = typeof entry === "string" ? entry : entry.name;
|
|
5858
|
-
|
|
6670
|
+
const isCombinedContentOnly = name === "knowledge.docs" || name === "knowledge.components";
|
|
6671
|
+
if (!KNOWN_WORKFLOWS.has(name) && !isCombinedContentOnly) {
|
|
5859
6672
|
steps.push({
|
|
5860
6673
|
capability: "local",
|
|
5861
6674
|
step: `sync workflow ${name}`,
|
|
@@ -5865,10 +6678,10 @@ async function runSync(input) {
|
|
|
5865
6678
|
print(formatSyncStep(steps[steps.length - 1]));
|
|
5866
6679
|
continue;
|
|
5867
6680
|
}
|
|
5868
|
-
const filename =
|
|
6681
|
+
const filename = `${name}.yml`;
|
|
5869
6682
|
const content = files.get(filename);
|
|
5870
6683
|
if (content === void 0) continue;
|
|
5871
|
-
const step = `sync workflow ${name}${
|
|
6684
|
+
const step = `sync workflow ${name}${content.includes("workflows/preview.yml@main") ? " (with preview)" : ""}`;
|
|
5872
6685
|
steps.push(await runSyncStep("local", step, dryRun, async () => {
|
|
5873
6686
|
await writeWorkflowFile(input.context.repoRoot, filename, `${workflowHeader()}${content}`);
|
|
5874
6687
|
}));
|
|
@@ -5876,29 +6689,76 @@ async function runSync(input) {
|
|
|
5876
6689
|
}
|
|
5877
6690
|
}
|
|
5878
6691
|
}
|
|
5879
|
-
if (stepName === "scripts")
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
6692
|
+
if (stepName === "scripts") {
|
|
6693
|
+
if (config.syncScripts === false) {
|
|
6694
|
+
steps.push({
|
|
6695
|
+
capability: "local",
|
|
6696
|
+
step: "sync scripts",
|
|
6697
|
+
status: "skip",
|
|
6698
|
+
message: "syncScripts: false"
|
|
6699
|
+
});
|
|
6700
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6701
|
+
} else {
|
|
6702
|
+
const tasksConfig = { tasks: config.tasks };
|
|
6703
|
+
if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
|
|
6704
|
+
const astromechScripts = createAstromech({
|
|
6705
|
+
cwd: input.context.repoRoot,
|
|
6706
|
+
config: tasksConfig,
|
|
6707
|
+
logger
|
|
6708
|
+
});
|
|
6709
|
+
const desired = astromechScripts.packageScripts();
|
|
6710
|
+
const turboContent = astromechScripts.turboConfig();
|
|
6711
|
+
const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
|
|
6712
|
+
steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
|
|
6713
|
+
const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
|
|
6714
|
+
if (changed === null) return "no package.json";
|
|
6715
|
+
if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
|
|
6716
|
+
return `${changed.join(", ")} set`;
|
|
6717
|
+
}));
|
|
6718
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6719
|
+
}
|
|
6720
|
+
}
|
|
6721
|
+
if (stepName === "turbo") {
|
|
6722
|
+
const astromechTurbo = createAstromech({
|
|
5891
6723
|
cwd: input.context.repoRoot,
|
|
5892
|
-
config:
|
|
6724
|
+
config: { tasks: config.tasks },
|
|
5893
6725
|
logger
|
|
5894
|
-
})
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
6726
|
+
});
|
|
6727
|
+
const content = astromechTurbo.turboConfig();
|
|
6728
|
+
if (content === null) {
|
|
6729
|
+
steps.push({
|
|
6730
|
+
capability: "local",
|
|
6731
|
+
step: "sync turbo.json",
|
|
6732
|
+
status: "skip",
|
|
6733
|
+
message: "no fan-out-eligible tasks configured"
|
|
6734
|
+
});
|
|
6735
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6736
|
+
} else {
|
|
6737
|
+
steps.push(await runSyncStep("local", "sync turbo.json", dryRun, async () => {
|
|
6738
|
+
await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
|
|
6739
|
+
}));
|
|
6740
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6741
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
6742
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
6743
|
+
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
6744
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
6745
|
+
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
6746
|
+
if (workspaceFix.changed) {
|
|
6747
|
+
steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
6748
|
+
await writeFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), workspaceFix.content, "utf8");
|
|
6749
|
+
}));
|
|
6750
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6751
|
+
}
|
|
6752
|
+
if (rootPkgRaw) {
|
|
6753
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
6754
|
+
if (depFix.changed) {
|
|
6755
|
+
steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
|
|
6756
|
+
await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
|
|
6757
|
+
}));
|
|
6758
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6759
|
+
}
|
|
6760
|
+
}
|
|
6761
|
+
}
|
|
5902
6762
|
}
|
|
5903
6763
|
if (stepName === "wiki") {
|
|
5904
6764
|
const result = await runSyncWiki({
|
|
@@ -5981,8 +6841,21 @@ async function writePackageJsonField(repoRoot, field, value) {
|
|
|
5981
6841
|
* Merge `desired` script entries into `package.json#scripts` — sets only the
|
|
5982
6842
|
* listed keys, never touches the rest. Returns the sorted list of keys that
|
|
5983
6843
|
* drifted (empty when all current), or `null` when there is no `package.json`.
|
|
6844
|
+
*
|
|
6845
|
+
* `turboEligible` (#747): task names `turbo.json` fans out — the same set
|
|
6846
|
+
* {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
|
|
6847
|
+
* only, an *existing* script that isn't the generated `holocron run <task>
|
|
6848
|
+
* --` wrapper is left untouched: root may genuinely be a directly-buildable
|
|
6849
|
+
* package (not an orchestrator) with its own real command (`tsc --noEmit`,
|
|
6850
|
+
* `vitest run`, …) for that task. Overwriting it would silently discard the
|
|
6851
|
+
* real command and, once `turbo.json` exists and root is a real workspace
|
|
6852
|
+
* member (#692), reintroduce infinite recursion — `turbo run <task>` →
|
|
6853
|
+
* root's own `<task>` script → the wrapper → `holocron run <task>` sees
|
|
6854
|
+
* `turbo.json` still defines the task → `turbo run <task>` again, forever.
|
|
6855
|
+
* Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
|
|
6856
|
+
* legitimate direct-command alternative and stays fully generator-owned.
|
|
5984
6857
|
*/
|
|
5985
|
-
async function mergePackageJsonScripts(repoRoot, desired) {
|
|
6858
|
+
async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
|
|
5986
6859
|
const pkgPath = join(repoRoot, "package.json");
|
|
5987
6860
|
let content;
|
|
5988
6861
|
try {
|
|
@@ -5993,15 +6866,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
|
|
|
5993
6866
|
const pkg = JSON.parse(content);
|
|
5994
6867
|
const scripts = pkg.scripts ?? {};
|
|
5995
6868
|
const changed = [];
|
|
5996
|
-
for (const [name, command] of Object.entries(desired))
|
|
5997
|
-
scripts[name]
|
|
5998
|
-
|
|
6869
|
+
for (const [name, command] of Object.entries(desired)) {
|
|
6870
|
+
const existing = scripts[name];
|
|
6871
|
+
if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
|
|
6872
|
+
if (existing !== command) {
|
|
6873
|
+
scripts[name] = command;
|
|
6874
|
+
changed.push(name);
|
|
6875
|
+
}
|
|
5999
6876
|
}
|
|
6000
6877
|
if (changed.length === 0) return [];
|
|
6001
6878
|
pkg.scripts = scripts;
|
|
6002
6879
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
6003
6880
|
return changed.sort();
|
|
6004
6881
|
}
|
|
6882
|
+
/**
|
|
6883
|
+
* Matches the exact shape `packageScripts()` generates for `taskName` —
|
|
6884
|
+
* `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
|
|
6885
|
+
* (`holocronScript`, itself allowed to be multi-word, e.g. `"node
|
|
6886
|
+
packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
|
|
6887
|
+
* rather than the whole string means an old wrapper survives a
|
|
6888
|
+
* `holocronScript` rename intact — it's still recognized as a wrapper (safe
|
|
6889
|
+
* to update to the new one), not mistaken for a real command.
|
|
6890
|
+
*/
|
|
6891
|
+
function isGeneratedRunWrapper(command, taskName) {
|
|
6892
|
+
return command.endsWith(` run ${taskName} --`);
|
|
6893
|
+
}
|
|
6005
6894
|
const README_DESC_START = "<!-- holocron:description -->";
|
|
6006
6895
|
const README_DESC_END = "<!-- /holocron:description -->";
|
|
6007
6896
|
async function updateReadmeDescription(repoRoot, description) {
|
|
@@ -6868,6 +7757,63 @@ async function runUpgradeNode(input) {
|
|
|
6868
7757
|
};
|
|
6869
7758
|
}
|
|
6870
7759
|
//#endregion
|
|
7760
|
+
//#region src/commit-lint/lint-message.ts
|
|
7761
|
+
/**
|
|
7762
|
+
* Lints one commit message string against an already-loaded commitlint
|
|
7763
|
+
* config. The genuinely environment-independent half of commit-message
|
|
7764
|
+
* linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
|
|
7765
|
+
* caller (Sentinel isolates itself from any ambient config since it runs
|
|
7766
|
+
* standalone, away from a real repo checkout; `lintCommitMsgFile` below
|
|
7767
|
+
* just lets cosmiconfig discover the invoking repo's own
|
|
7768
|
+
* `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
|
|
7769
|
+
* but linting one message against a loaded config is identical either way.
|
|
7770
|
+
*
|
|
7771
|
+
* Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
|
|
7772
|
+
* itself uses internally — never a reimplementation of commitlint's rules.
|
|
7773
|
+
*/
|
|
7774
|
+
/** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
|
|
7775
|
+
function selectParserOpts(parserPreset) {
|
|
7776
|
+
return parserPreset?.parserOpts;
|
|
7777
|
+
}
|
|
7778
|
+
async function lintCommitMessage(message, loaded) {
|
|
7779
|
+
const opts = {
|
|
7780
|
+
/* istanbul ignore next -- see comment above */
|
|
7781
|
+
parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
|
|
7782
|
+
plugins: loaded.plugins,
|
|
7783
|
+
/* istanbul ignore next -- see comment above */
|
|
7784
|
+
ignores: loaded.ignores ?? [],
|
|
7785
|
+
defaultIgnores: loaded.defaultIgnores !== false
|
|
7786
|
+
};
|
|
7787
|
+
const outcome = await lint(message, loaded.rules, opts);
|
|
7788
|
+
if (outcome.valid) return [];
|
|
7789
|
+
return outcome.errors.map((error) => ({
|
|
7790
|
+
rule: error.name,
|
|
7791
|
+
message: error.message
|
|
7792
|
+
}));
|
|
7793
|
+
}
|
|
7794
|
+
//#endregion
|
|
7795
|
+
//#region src/commit-lint/lint-commit-msg-file.ts
|
|
7796
|
+
/**
|
|
7797
|
+
* `holocron lint commit-msg <file>` — a real programmatic replacement for
|
|
7798
|
+
* shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
|
|
7799
|
+
* (holocron#789). Matches that CLI's own contract exactly: `file` is a
|
|
7800
|
+
* path to the commit message being written (the arg git's `commit-msg`
|
|
7801
|
+
* hook passes as `$1`), no `--config` override needed — `@commitlint/load`
|
|
7802
|
+
* runs from `cwd` with no seed, so cosmiconfig's own normal upward search
|
|
7803
|
+
* discovers the invoking repo's real `commitlint.config.*` file, exactly
|
|
7804
|
+
* as `commitlint --edit` (no `--config` flag) already does today. Unlike
|
|
7805
|
+
* Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
|
|
7806
|
+
* runs inside a real repo checkout with a real `node_modules`, not
|
|
7807
|
+
* standalone on a server.
|
|
7808
|
+
*/
|
|
7809
|
+
async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
|
|
7810
|
+
const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
|
|
7811
|
+
return {
|
|
7812
|
+
valid: violations.length === 0,
|
|
7813
|
+
violations
|
|
7814
|
+
};
|
|
7815
|
+
}
|
|
7816
|
+
//#endregion
|
|
6871
7817
|
//#region src/config/load-config.ts
|
|
6872
7818
|
/**
|
|
6873
7819
|
* `holocron.config.{ts,js,mjs,cjs,json}` loader.
|
|
@@ -6978,7 +7924,9 @@ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
|
6978
7924
|
* The full command surface, hand-maintained in parallel with `cli.ts`'s
|
|
6979
7925
|
* yargs registrations (this module does not introspect yargs at runtime).
|
|
6980
7926
|
* `run <task>` is deliberately excluded — it's CI/scripting-oriented, and an
|
|
6981
|
-
* interactive prompt in front of it would work against that.
|
|
7927
|
+
* interactive prompt in front of it would work against that. `lint
|
|
7928
|
+
* commit-msg <file>` is excluded for the same reason: it's invoked
|
|
7929
|
+
* exclusively from `.husky/commit-msg "$1"`, never typed by a human.
|
|
6982
7930
|
*/
|
|
6983
7931
|
const COMMAND_REGISTRY = [
|
|
6984
7932
|
{
|
|
@@ -7289,7 +8237,7 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
|
|
|
7289
8237
|
//#endregion
|
|
7290
8238
|
//#region src/update-notifier.ts
|
|
7291
8239
|
const PACKAGE_NAME = "@theholocron/cli";
|
|
7292
|
-
const CACHE_TTL_MS =
|
|
8240
|
+
const CACHE_TTL_MS = 864e5;
|
|
7293
8241
|
const FETCH_TIMEOUT_MS = 3e3;
|
|
7294
8242
|
function getCacheDir() {
|
|
7295
8243
|
return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
|
|
@@ -7655,9 +8603,17 @@ try {
|
|
|
7655
8603
|
type: "array",
|
|
7656
8604
|
default: ["production", "preview"],
|
|
7657
8605
|
describe: "Deployment targets to sync to. Defaults to production + preview."
|
|
8606
|
+
}).option("github-secret", {
|
|
8607
|
+
type: "array",
|
|
8608
|
+
describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
|
|
8609
|
+
}).option("github-secret-scope", {
|
|
8610
|
+
type: "string",
|
|
8611
|
+
default: "repo",
|
|
8612
|
+
describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
|
|
7658
8613
|
}), async (argv) => {
|
|
7659
8614
|
const tokens = tokenContext(argv.token);
|
|
7660
8615
|
if (!tokens) return;
|
|
8616
|
+
const githubSecretScope = parseScope(argv.githubSecretScope);
|
|
7661
8617
|
const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
|
|
7662
8618
|
const loaded = await loadConfig(argv.cwd);
|
|
7663
8619
|
applyResolvedConfig(argv, loaded.resolved);
|
|
@@ -7671,11 +8627,13 @@ try {
|
|
|
7671
8627
|
},
|
|
7672
8628
|
environmentId,
|
|
7673
8629
|
...argv.projectId ? { projectId: argv.projectId } : {},
|
|
8630
|
+
...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
|
|
8631
|
+
githubSecretScope,
|
|
7674
8632
|
targets: argv.target
|
|
7675
8633
|
})).summary.fail > 0) process.exitCode = 1;
|
|
7676
8634
|
}).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
|
|
7677
8635
|
type: "string",
|
|
7678
|
-
describe: "Git branch to deploy"
|
|
8636
|
+
describe: "Git branch to deploy (omit when using --files)"
|
|
7679
8637
|
}).option("project-id", {
|
|
7680
8638
|
type: "string",
|
|
7681
8639
|
demandOption: true,
|
|
@@ -7684,20 +8642,34 @@ try {
|
|
|
7684
8642
|
type: "string",
|
|
7685
8643
|
choices: ["production", "staging"],
|
|
7686
8644
|
describe: "Named environment to deploy into. Omit for a branch preview."
|
|
7687
|
-
}),
|
|
8645
|
+
}).option("files", {
|
|
8646
|
+
type: "string",
|
|
8647
|
+
describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
|
|
8648
|
+
}).conflicts("files", "branch"), async (argv) => {
|
|
7688
8649
|
const tokens = tokenContext(argv.token);
|
|
7689
8650
|
if (!tokens) return;
|
|
7690
|
-
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
7691
8651
|
const loaded = await loadConfig(argv.cwd);
|
|
7692
8652
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8653
|
+
const context = {
|
|
8654
|
+
repoRoot: argv.cwd,
|
|
8655
|
+
dryRun: argv.dryRun,
|
|
8656
|
+
...tokens,
|
|
8657
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
8658
|
+
};
|
|
8659
|
+
if (argv.files) {
|
|
8660
|
+
if ((await runDeployFromFiles({
|
|
8661
|
+
loaded,
|
|
8662
|
+
context,
|
|
8663
|
+
projectId: argv.projectId,
|
|
8664
|
+
dir: argv.files,
|
|
8665
|
+
...argv.target ? { target: argv.target } : {}
|
|
8666
|
+
})).status === "fail") process.exitCode = 1;
|
|
8667
|
+
return;
|
|
8668
|
+
}
|
|
8669
|
+
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
7693
8670
|
if ((await runDeploy({
|
|
7694
8671
|
loaded,
|
|
7695
|
-
context
|
|
7696
|
-
repoRoot: argv.cwd,
|
|
7697
|
-
dryRun: argv.dryRun,
|
|
7698
|
-
...tokens,
|
|
7699
|
-
org: resolveOrg(argv, loaded.resolved)
|
|
7700
|
-
},
|
|
8672
|
+
context,
|
|
7701
8673
|
projectId: argv.projectId,
|
|
7702
8674
|
branch,
|
|
7703
8675
|
...argv.target ? { target: argv.target } : {}
|
|
@@ -7743,24 +8715,59 @@ try {
|
|
|
7743
8715
|
}).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
|
|
7744
8716
|
type: "boolean",
|
|
7745
8717
|
default: false,
|
|
7746
|
-
describe: "One-shot bootstrap publish for trusted-publishing-eligible packages (npm needs the package to exist before Trusted Publishing can be configured for it).
|
|
8718
|
+
describe: "One-shot bootstrap publish for trusted-publishing-eligible packages (npm needs the package to exist before Trusted Publishing can be configured for it). Skips the steady-state OIDC path below — use only for a package's very first publish."
|
|
7747
8719
|
}).option("tag", {
|
|
7748
8720
|
type: "string",
|
|
7749
|
-
|
|
7750
|
-
describe: "npm distribution tag (defaults to alpha)"
|
|
8721
|
+
describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
|
|
7751
8722
|
}).option("otp", {
|
|
7752
8723
|
type: "string",
|
|
7753
8724
|
describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
|
|
8725
|
+
}).option("sync-trust", {
|
|
8726
|
+
type: "array",
|
|
8727
|
+
describe: "Migrate npm Trusted Publisher config to a new workflow filename across every public package: --sync-trust <old-file> <new-file>. See issue #689 — npm requires fresh interactive 2FA per package; re-run after approving the printed authUrl if it stops early."
|
|
8728
|
+
}).option("repo", {
|
|
8729
|
+
type: "string",
|
|
8730
|
+
describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
|
|
8731
|
+
}).option("provenance", {
|
|
8732
|
+
type: "boolean",
|
|
8733
|
+
default: true,
|
|
8734
|
+
describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
|
|
8735
|
+
}).option("skip-already-published", {
|
|
8736
|
+
type: "boolean",
|
|
8737
|
+
default: false,
|
|
8738
|
+
describe: "Steady-state only: check `npm view <pkg>@<version>` before publishing each package and skip it if that exact version already exists, instead of one bulk publish. For repos that ship many independently-versioned packages where a release doesn't bump every one."
|
|
7754
8739
|
}), async (argv) => {
|
|
7755
|
-
if (
|
|
7756
|
-
|
|
7757
|
-
|
|
8740
|
+
if (argv.syncTrust) {
|
|
8741
|
+
const [oldFile, newFile] = argv.syncTrust;
|
|
8742
|
+
if (!oldFile || !newFile) {
|
|
8743
|
+
getLogger().error("publish --sync-trust requires exactly two values: <old-file> <new-file>");
|
|
8744
|
+
process.exitCode = 1;
|
|
8745
|
+
return;
|
|
8746
|
+
}
|
|
8747
|
+
if ((await runSyncTrust({
|
|
8748
|
+
cwd: argv.cwd,
|
|
8749
|
+
oldFile,
|
|
8750
|
+
newFile,
|
|
8751
|
+
dryRun: argv.dryRun,
|
|
8752
|
+
...argv.repo ? { repo: argv.repo } : {}
|
|
8753
|
+
})).status === "fail") process.exitCode = 1;
|
|
8754
|
+
return;
|
|
8755
|
+
}
|
|
8756
|
+
if (argv.initial) {
|
|
8757
|
+
if ((await runPublish({
|
|
8758
|
+
cwd: argv.cwd,
|
|
8759
|
+
dryRun: argv.dryRun,
|
|
8760
|
+
...argv.tag ? { tag: argv.tag } : {},
|
|
8761
|
+
...argv.otp ? { otp: argv.otp } : {}
|
|
8762
|
+
})).status === "fail") process.exitCode = 1;
|
|
7758
8763
|
return;
|
|
7759
8764
|
}
|
|
7760
|
-
if ((await
|
|
8765
|
+
if ((await runSteadyPublish({
|
|
7761
8766
|
cwd: argv.cwd,
|
|
7762
|
-
tag: argv.tag,
|
|
7763
8767
|
dryRun: argv.dryRun,
|
|
8768
|
+
provenance: argv.provenance,
|
|
8769
|
+
skipAlreadyPublished: argv.skipAlreadyPublished,
|
|
8770
|
+
...argv.tag ? { tag: argv.tag } : {},
|
|
7764
8771
|
...argv.otp ? { otp: argv.otp } : {}
|
|
7765
8772
|
})).status === "fail") process.exitCode = 1;
|
|
7766
8773
|
}).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)", (y) => y.positional("steps", {
|
|
@@ -7838,7 +8845,17 @@ try {
|
|
|
7838
8845
|
scope: argv.all ? "all" : "required",
|
|
7839
8846
|
...argv.filter ? { filter: argv.filter } : {}
|
|
7840
8847
|
}).status === "fail") process.exitCode = 1;
|
|
7841
|
-
}).command("
|
|
8848
|
+
}).command("lint", "Lint content locally against the org's shared rules", (y) => y.command("commit-msg <file>", "Lint a commit message file — matches `commitlint --edit`'s own contract, for use from .husky/commit-msg", (yy) => yy.positional("file", {
|
|
8849
|
+
type: "string",
|
|
8850
|
+
demandOption: true,
|
|
8851
|
+
describe: "Path to the commit message file (git's commit-msg hook passes this as $1)."
|
|
8852
|
+
}), async (argv) => {
|
|
8853
|
+
const result = await lintCommitMsgFile(argv.file, argv.cwd);
|
|
8854
|
+
if (!result.valid) {
|
|
8855
|
+
for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
|
|
8856
|
+
process.exitCode = 1;
|
|
8857
|
+
}
|
|
8858
|
+
}), () => {}).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
|
|
7842
8859
|
type: "string",
|
|
7843
8860
|
default: "theholocron/.github",
|
|
7844
8861
|
describe: "Target org/repo (default: theholocron/.github)"
|