@theholocron/cli 5.0.0-alpha.11 → 5.0.0-alpha.111
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 +127 -2
- package/dist/cli.mjs +753 -203
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +208 -2
- package/dist/index.mjs +263 -3
- package/dist/plugin/capabilities.d.mts +91 -23
- package/package.json +15 -10
package/dist/cli.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
import { KNOWN_WORKFLOWS, createAstromech, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
|
|
6
|
+
import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
|
|
7
7
|
import { loadTasksConfig } from "@theholocron/astromech/config";
|
|
8
8
|
import yargs from "yargs";
|
|
9
9
|
import { hideBin } from "yargs/helpers";
|
|
@@ -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,8 +1222,10 @@ 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",
|
|
1228
|
+
"deploy-on-release": "repo-aware",
|
|
1219
1229
|
"config show": "repo-aware",
|
|
1220
1230
|
"sync-readme": "repo-aware",
|
|
1221
1231
|
doctor: "workspace",
|
|
@@ -1233,6 +1243,80 @@ function commandsInContext(context) {
|
|
|
1233
1243
|
}
|
|
1234
1244
|
//#endregion
|
|
1235
1245
|
//#region src/commands/deploy.ts
|
|
1246
|
+
/**
|
|
1247
|
+
* `holocron deploy` — trigger a deployment via the configured
|
|
1248
|
+
* `deployment` capability.
|
|
1249
|
+
*
|
|
1250
|
+
* Two modes, one command: `runDeploy` (the default — a Git branch,
|
|
1251
|
+
* `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
|
|
1252
|
+
* a local directory of source files, `deployFunction()`, no linked
|
|
1253
|
+
* repo required — for a consumer with no Git history to deploy from,
|
|
1254
|
+
* e.g. Sentinel, shipped as an npm package). Separate functions, not
|
|
1255
|
+
* one branching `runDeploy`, because their inputs genuinely differ
|
|
1256
|
+
* (`branch` vs `dir`) and their result shapes differ too
|
|
1257
|
+
* (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
|
|
1258
|
+
* is deliberately minimal, same as `DeployScriptResult` for Workers).
|
|
1259
|
+
*
|
|
1260
|
+
* Errors clearly when `deployment` isn't loaded (no provider declared
|
|
1261
|
+
* in `holocron.config.json`), or when the configured provider doesn't
|
|
1262
|
+
* implement `deployFunction` (it's optional on `Deployment` — not
|
|
1263
|
+
* every provider has a files-based deploy API).
|
|
1264
|
+
*
|
|
1265
|
+
* Dry-run skips the actual deploy call but still verifies the
|
|
1266
|
+
* deployment capability (and, for `--files`, `deployFunction`) is
|
|
1267
|
+
* present, so operators can sanity-check the wiring without spinning
|
|
1268
|
+
* up a build.
|
|
1269
|
+
*
|
|
1270
|
+
* **Both modes wait for the deployment to finish** (holocron#911): a
|
|
1271
|
+
* provider accepting the upload/trigger is not the deployment succeeding.
|
|
1272
|
+
* Vercel builds asynchronously — an `npm install` failure there leaves
|
|
1273
|
+
* the deployment in `ERROR` while production keeps serving the previous
|
|
1274
|
+
* one, and this command used to report `ok` regardless (found live:
|
|
1275
|
+
* Sentinel's holocron#910 deploy "succeeded" in CI, never went live). So
|
|
1276
|
+
* after triggering, {@link waitForDeployment} polls
|
|
1277
|
+
* `Deployment.getDeployment()` until a terminal state; only `ready` is
|
|
1278
|
+
* `ok`, while `error` / `cancelled` / a timeout are `fail` (non-zero exit
|
|
1279
|
+
* via `cli.ts`).
|
|
1280
|
+
*/
|
|
1281
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 6e5;
|
|
1282
|
+
const DEFAULT_WAIT_INTERVAL_MS = 5e3;
|
|
1283
|
+
const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
1284
|
+
"ready",
|
|
1285
|
+
"error",
|
|
1286
|
+
"cancelled"
|
|
1287
|
+
]);
|
|
1288
|
+
/**
|
|
1289
|
+
* Polls `getDeployment(id)` until the deployment reaches a terminal status
|
|
1290
|
+
* (`ready` / `error` / `cancelled`). Throws on timeout — the caller turns
|
|
1291
|
+
* that into a `fail` report, same as a provider throwing mid-trigger.
|
|
1292
|
+
*/
|
|
1293
|
+
async function waitForDeployment(deploy, deploymentId, options = {}) {
|
|
1294
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
1295
|
+
const intervalMs = options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
|
|
1296
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1297
|
+
const now = options.now ?? Date.now;
|
|
1298
|
+
const deadline = now() + timeoutMs;
|
|
1299
|
+
for (;;) {
|
|
1300
|
+
const record = await deploy.getDeployment(deploymentId);
|
|
1301
|
+
if (TERMINAL_STATUSES.has(record.status)) return record;
|
|
1302
|
+
if (now() >= deadline) throw new Error(`deployment ${deploymentId} still ${record.status} after ${Math.round(timeoutMs / 1e3)}s — check the provider's dashboard`);
|
|
1303
|
+
await sleep(intervalMs);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
/** One terminal, non-ready record as a single human-readable failure line. */
|
|
1307
|
+
function terminalFailure(record) {
|
|
1308
|
+
return `deployment ${record.id} ended ${record.status}${record.errorMessage ? `: ${record.errorMessage}` : ""}`;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Provider `url` fields (Vercel's REST API, at least) come back as bare
|
|
1312
|
+
* hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
|
|
1313
|
+
* linkify and browsers won't navigate to unmodified. Printed output
|
|
1314
|
+
* should be a URL a human can click/paste; structured log fields keep
|
|
1315
|
+
* the raw provider value as-is.
|
|
1316
|
+
*/
|
|
1317
|
+
function withScheme(url) {
|
|
1318
|
+
return /^https?:\/\//.test(url) ? url : `https://${url}`;
|
|
1319
|
+
}
|
|
1236
1320
|
async function runDeploy(input) {
|
|
1237
1321
|
const print = input.print ?? ((line) => console.log(line));
|
|
1238
1322
|
const logger = input.logger ?? getLogger();
|
|
@@ -1259,17 +1343,37 @@ async function runDeploy(input) {
|
|
|
1259
1343
|
};
|
|
1260
1344
|
}
|
|
1261
1345
|
try {
|
|
1262
|
-
const
|
|
1346
|
+
const triggered = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
|
|
1263
1347
|
projectId: input.projectId,
|
|
1264
1348
|
branch: input.branch,
|
|
1265
1349
|
...input.target ? { target: input.target } : {}
|
|
1266
1350
|
}));
|
|
1267
|
-
|
|
1351
|
+
logger.info({
|
|
1352
|
+
status: triggered.status,
|
|
1353
|
+
url: triggered.url,
|
|
1354
|
+
id: triggered.id
|
|
1355
|
+
}, "deploy: triggered");
|
|
1356
|
+
const record = TERMINAL_STATUSES.has(triggered.status) ? triggered : await withSpinner(`Waiting for ${withScheme(triggered.url)} to finish…`, () => waitForDeployment(deploy, triggered.id, input.wait));
|
|
1357
|
+
if (record.status !== "ready") {
|
|
1358
|
+
const message = terminalFailure(record);
|
|
1359
|
+
print(` ${style.fail(`${message} — ${withScheme(triggered.url)}`)}`);
|
|
1360
|
+
logger.warn({
|
|
1361
|
+
id: record.id,
|
|
1362
|
+
status: record.status,
|
|
1363
|
+
reason: record.errorMessage
|
|
1364
|
+
}, "deploy: failed");
|
|
1365
|
+
return {
|
|
1366
|
+
deployment: record,
|
|
1367
|
+
status: "fail",
|
|
1368
|
+
message
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
print(` ${style.success(`${record.status} — ${withScheme(triggered.url)}`)}`);
|
|
1268
1372
|
logger.info({
|
|
1269
1373
|
status: record.status,
|
|
1270
|
-
url:
|
|
1374
|
+
url: triggered.url,
|
|
1271
1375
|
id: record.id
|
|
1272
|
-
}, "deploy:
|
|
1376
|
+
}, "deploy: ready");
|
|
1273
1377
|
return {
|
|
1274
1378
|
deployment: record,
|
|
1275
1379
|
status: "ok"
|
|
@@ -1288,6 +1392,106 @@ async function runDeploy(input) {
|
|
|
1288
1392
|
};
|
|
1289
1393
|
}
|
|
1290
1394
|
}
|
|
1395
|
+
const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
|
|
1396
|
+
".git",
|
|
1397
|
+
"node_modules",
|
|
1398
|
+
".turbo"
|
|
1399
|
+
]);
|
|
1400
|
+
/** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
|
|
1401
|
+
function defaultWalkFiles$2(dir) {
|
|
1402
|
+
const results = [];
|
|
1403
|
+
for (const entry of readdirSync(dir)) {
|
|
1404
|
+
if (SKIP_DIRS$2.has(entry)) continue;
|
|
1405
|
+
const full = join(dir, entry);
|
|
1406
|
+
const stat = statSync(full);
|
|
1407
|
+
if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
|
|
1408
|
+
else if (stat.isFile()) results.push(full);
|
|
1409
|
+
}
|
|
1410
|
+
return results;
|
|
1411
|
+
}
|
|
1412
|
+
/** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
|
|
1413
|
+
async function runDeployFromFiles(input) {
|
|
1414
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
1415
|
+
const logger = input.logger ?? getLogger();
|
|
1416
|
+
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
1417
|
+
await loader.load();
|
|
1418
|
+
assertPluginsResolvable(loader, "deploy");
|
|
1419
|
+
const dryRun = input.context.dryRun ?? false;
|
|
1420
|
+
const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
|
|
1421
|
+
const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
1422
|
+
const files = {};
|
|
1423
|
+
for (const abs of walkFiles(input.dir)) {
|
|
1424
|
+
const rel = relative(input.dir, abs).split(sep).join("/");
|
|
1425
|
+
files[rel] = readFile(abs);
|
|
1426
|
+
}
|
|
1427
|
+
const fileCount = Object.keys(files).length;
|
|
1428
|
+
logger.info({
|
|
1429
|
+
dir: input.dir,
|
|
1430
|
+
fileCount,
|
|
1431
|
+
target: input.target ?? "preview",
|
|
1432
|
+
projectId: input.projectId,
|
|
1433
|
+
dryRun: dryRun || void 0
|
|
1434
|
+
}, "deploy: start (files)");
|
|
1435
|
+
print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
|
|
1436
|
+
if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
|
|
1437
|
+
const deploy = loader.get("deployment");
|
|
1438
|
+
if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
|
|
1439
|
+
if (dryRun) {
|
|
1440
|
+
const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
|
|
1441
|
+
print(` ${style.dim(`… ${message}`)}`);
|
|
1442
|
+
return {
|
|
1443
|
+
deployment: null,
|
|
1444
|
+
status: "dry-run",
|
|
1445
|
+
message
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
try {
|
|
1449
|
+
const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
|
|
1450
|
+
files,
|
|
1451
|
+
...input.target ? { target: input.target } : {}
|
|
1452
|
+
}));
|
|
1453
|
+
logger.info({
|
|
1454
|
+
url: result.url,
|
|
1455
|
+
id: result.deploymentId
|
|
1456
|
+
}, "deploy: triggered (files)");
|
|
1457
|
+
const record = await withSpinner(`Waiting for ${withScheme(result.url)} to finish…`, () => waitForDeployment(deploy, result.deploymentId, input.wait));
|
|
1458
|
+
if (record.status !== "ready") {
|
|
1459
|
+
const message = terminalFailure(record);
|
|
1460
|
+
print(` ${style.fail(`${message} — ${withScheme(result.url)}`)}`);
|
|
1461
|
+
logger.warn({
|
|
1462
|
+
id: record.id,
|
|
1463
|
+
status: record.status,
|
|
1464
|
+
reason: record.errorMessage
|
|
1465
|
+
}, "deploy: failed (files)");
|
|
1466
|
+
return {
|
|
1467
|
+
deployment: result,
|
|
1468
|
+
status: "fail",
|
|
1469
|
+
message
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
print(` ${style.success(withScheme(result.url))}`);
|
|
1473
|
+
logger.info({
|
|
1474
|
+
url: result.url,
|
|
1475
|
+
id: result.deploymentId
|
|
1476
|
+
}, "deploy: ready (files)");
|
|
1477
|
+
return {
|
|
1478
|
+
deployment: result,
|
|
1479
|
+
status: "ok"
|
|
1480
|
+
};
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1483
|
+
print(` ${style.fail(message)}`);
|
|
1484
|
+
logger.warn({
|
|
1485
|
+
dir: input.dir,
|
|
1486
|
+
reason: message
|
|
1487
|
+
}, "deploy: failed (files)");
|
|
1488
|
+
return {
|
|
1489
|
+
deployment: null,
|
|
1490
|
+
status: "fail",
|
|
1491
|
+
message
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1291
1495
|
//#endregion
|
|
1292
1496
|
//#region src/commands/doctor.ts
|
|
1293
1497
|
async function runDoctor(input) {
|
|
@@ -1963,14 +2167,16 @@ describe("resolveToken", () => {
|
|
|
1963
2167
|
});
|
|
1964
2168
|
|
|
1965
2169
|
it("throws AuthError with a helpful message when nothing is set", () => {
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
2170
|
+
const err = (() => {
|
|
2171
|
+
try {
|
|
2172
|
+
resolveToken({ env: {}, keyring: noKeyring });
|
|
2173
|
+
} catch (e) {
|
|
2174
|
+
return e;
|
|
2175
|
+
}
|
|
2176
|
+
})();
|
|
2177
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
2178
|
+
expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
|
|
2179
|
+
expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
|
|
1974
2180
|
});
|
|
1975
2181
|
});
|
|
1976
2182
|
`;
|
|
@@ -2014,24 +2220,24 @@ export class ${inputs.capabilityClass} {
|
|
|
2014
2220
|
//#endregion
|
|
2015
2221
|
//#region src/commands/plugin-create/templates/capability-test.ts
|
|
2016
2222
|
function render$14(inputs) {
|
|
2017
|
-
const
|
|
2018
|
-
return `import { describe, it } from "vitest";
|
|
2223
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2224
|
+
return `import { describe, expect, it } from "vitest";
|
|
2019
2225
|
|
|
2020
2226
|
import { ${inputs.capabilityClass} } from "../capabilities/${inputs.capability}.js";
|
|
2021
|
-
import { ${
|
|
2227
|
+
import { ${factoryName} } from "../rest.js";
|
|
2022
2228
|
import { stubFetch } from "./helpers.js";
|
|
2023
2229
|
|
|
2024
2230
|
// Stub client used by the constructor smoke test. Real capability
|
|
2025
2231
|
// tests replace this with per-method stubs once implementations land.
|
|
2026
2232
|
function makeCapability() {
|
|
2027
2233
|
const stub = stubFetch([]);
|
|
2028
|
-
const rest =
|
|
2234
|
+
const rest = ${factoryName}({ token: "t", fetch: stub.fetch });
|
|
2029
2235
|
return new ${inputs.capabilityClass}(() => rest);
|
|
2030
2236
|
}
|
|
2031
2237
|
|
|
2032
2238
|
describe("${inputs.capabilityClass}", () => {
|
|
2033
2239
|
it("constructs with a REST client", () => {
|
|
2034
|
-
makeCapability();
|
|
2240
|
+
expect(makeCapability().key).toBe("${inputs.capability}");
|
|
2035
2241
|
});
|
|
2036
2242
|
|
|
2037
2243
|
// TODO: implement one test per ${inputs.capability} capability method
|
|
@@ -2047,14 +2253,11 @@ describe("${inputs.capabilityClass}", () => {
|
|
|
2047
2253
|
//#endregion
|
|
2048
2254
|
//#region src/commands/plugin-create/templates/eslint-config.ts
|
|
2049
2255
|
function render$13(_inputs) {
|
|
2050
|
-
return `import
|
|
2256
|
+
return `import type { Linter } from "eslint";
|
|
2051
2257
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
ignores: ["dist/**", "coverage/**"],
|
|
2056
|
-
},
|
|
2057
|
-
];
|
|
2258
|
+
import root from "../../eslint.config.js";
|
|
2259
|
+
|
|
2260
|
+
export default [...root, { ignores: ["dist/**", "coverage/**"] }] satisfies Linter.Config[];
|
|
2058
2261
|
`;
|
|
2059
2262
|
}
|
|
2060
2263
|
//#endregion
|
|
@@ -2137,10 +2340,17 @@ describe("AUTH_HINT", () => {
|
|
|
2137
2340
|
//#endregion
|
|
2138
2341
|
//#region src/commands/plugin-create/templates/package-json.ts
|
|
2139
2342
|
function render$10(inputs) {
|
|
2343
|
+
const keywords = [
|
|
2344
|
+
inputs.capability,
|
|
2345
|
+
"holocron",
|
|
2346
|
+
"plugin",
|
|
2347
|
+
inputs.slug
|
|
2348
|
+
].sort((a, b) => a.localeCompare(b));
|
|
2140
2349
|
return JSON.stringify({
|
|
2141
2350
|
name: `@theholocron/holocron-plugin-${inputs.slug}`,
|
|
2142
2351
|
version: "2.0.0-alpha.1",
|
|
2143
2352
|
description: `Holocron plugin for ${inputs.vendorName}. Implements the ${inputs.capability} capability against ${inputs.vendorName}'s REST API, plus exports verifyToken + AUTH_HINT for \`holocron auth\`.`,
|
|
2353
|
+
keywords,
|
|
2144
2354
|
homepage: `https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-${inputs.slug}#readme`,
|
|
2145
2355
|
bugs: "https://github.com/theholocron/holocron/issues",
|
|
2146
2356
|
repository: {
|
|
@@ -2150,9 +2360,13 @@ function render$10(inputs) {
|
|
|
2150
2360
|
},
|
|
2151
2361
|
license: "MIT",
|
|
2152
2362
|
author: "Newton Koumantzelis",
|
|
2363
|
+
sideEffects: false,
|
|
2153
2364
|
type: "module",
|
|
2154
|
-
|
|
2155
|
-
|
|
2365
|
+
exports: { ".": {
|
|
2366
|
+
types: "./dist/index.d.mts",
|
|
2367
|
+
import: "./dist/index.mjs",
|
|
2368
|
+
default: "./dist/index.mjs"
|
|
2369
|
+
} },
|
|
2156
2370
|
scripts: {
|
|
2157
2371
|
"delivery.build": "tsdown",
|
|
2158
2372
|
"sourceQuality.staticAnalysis": "holocron run sourceQuality.staticAnalysis --",
|
|
@@ -2164,33 +2378,26 @@ function render$10(inputs) {
|
|
|
2164
2378
|
peerDependencies: { "@theholocron/cli": "workspace:*" },
|
|
2165
2379
|
devDependencies: {
|
|
2166
2380
|
"@theholocron/cli": "workspace:*",
|
|
2167
|
-
"@theholocron/tsconfig": "catalog:",
|
|
2168
|
-
"@
|
|
2381
|
+
"@theholocron/tsconfig": "catalog:configs",
|
|
2382
|
+
"@types/node": "catalog:",
|
|
2169
2383
|
"@vitest/coverage-v8": "catalog:",
|
|
2170
2384
|
eslint: "catalog:",
|
|
2171
2385
|
globals: "catalog:",
|
|
2172
|
-
typescript: "catalog:",
|
|
2173
|
-
vitest: "catalog:",
|
|
2174
2386
|
tsdown: "catalog:",
|
|
2175
|
-
tsx: "catalog:"
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
access: "public",
|
|
2179
|
-
main: "./dist/index.mjs",
|
|
2180
|
-
types: "./dist/index.d.mts",
|
|
2181
|
-
exports: { ".": {
|
|
2182
|
-
types: "./dist/index.d.mts",
|
|
2183
|
-
import: "./dist/index.mjs",
|
|
2184
|
-
default: "./dist/index.mjs"
|
|
2185
|
-
} }
|
|
2387
|
+
tsx: "catalog:",
|
|
2388
|
+
typescript: "catalog:",
|
|
2389
|
+
vitest: "catalog:"
|
|
2186
2390
|
},
|
|
2187
|
-
|
|
2188
|
-
|
|
2391
|
+
engines: { node: ">=22" },
|
|
2392
|
+
publishConfig: { access: "public" },
|
|
2393
|
+
files: ["dist"]
|
|
2394
|
+
}, null, 2) + "\n";
|
|
2189
2395
|
}
|
|
2190
2396
|
//#endregion
|
|
2191
2397
|
//#region src/commands/plugin-create/templates/plugin-index.ts
|
|
2192
2398
|
function render$9(inputs) {
|
|
2193
2399
|
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2400
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2194
2401
|
const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
|
|
2195
2402
|
return `/**
|
|
2196
2403
|
* \`@theholocron/holocron-plugin-${inputs.slug}\` — entrypoint.
|
|
@@ -2207,7 +2414,7 @@ function render$9(inputs) {
|
|
|
2207
2414
|
|
|
2208
2415
|
import { resolveToken, type ResolveTokenInput } from "./auth.js";
|
|
2209
2416
|
import { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
2210
|
-
import { ${clientClass} } from "./rest.js";
|
|
2417
|
+
import { ${factoryName}, type ${clientClass} } from "./rest.js";
|
|
2211
2418
|
|
|
2212
2419
|
export interface ${inputs.vendorName}PluginOptions extends ResolveTokenInput {
|
|
2213
2420
|
/** Override base URL for tests. */
|
|
@@ -2228,10 +2435,12 @@ export function createContext(options: ${inputs.vendorName}PluginOptions): Plugi
|
|
|
2228
2435
|
options,
|
|
2229
2436
|
rest: () => {
|
|
2230
2437
|
if (rest) return rest;
|
|
2231
|
-
const restOpts:
|
|
2438
|
+
const restOpts: { token: string; baseUrl?: string; fetch?: typeof fetch } = {
|
|
2439
|
+
token: resolveToken(options),
|
|
2440
|
+
};
|
|
2232
2441
|
if (options.baseUrl !== undefined) restOpts.baseUrl = options.baseUrl;
|
|
2233
2442
|
if (options.fetch !== undefined) restOpts.fetch = options.fetch;
|
|
2234
|
-
return (rest =
|
|
2443
|
+
return (rest = ${factoryName}(restOpts));
|
|
2235
2444
|
},
|
|
2236
2445
|
};
|
|
2237
2446
|
}
|
|
@@ -2265,7 +2474,7 @@ export const AUTH_HINT =
|
|
|
2265
2474
|
// ── Public re-exports ────────────────────────────────────────────────
|
|
2266
2475
|
|
|
2267
2476
|
export * from "./auth.js";
|
|
2268
|
-
export { ${clientClass} } from "./rest.js";
|
|
2477
|
+
export { type ${clientClass}, ${factoryName} } from "./rest.js";
|
|
2269
2478
|
export { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
2270
2479
|
export { verifyToken } from "./verify-token.js";
|
|
2271
2480
|
export type { VerifyTokenResult, VerifyTokenSuccess, VerifyTokenFailure } from "./verify-token.js";
|
|
@@ -2342,15 +2551,26 @@ Not yet published; capability methods are stubs.
|
|
|
2342
2551
|
//#endregion
|
|
2343
2552
|
//#region src/commands/plugin-create/templates/rest.ts
|
|
2344
2553
|
function render$7(inputs) {
|
|
2345
|
-
|
|
2554
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2555
|
+
const clientType = `${inputs.vendorName}RestClient`;
|
|
2556
|
+
return `import { createRestClient, type RestClient } from "@theholocron/cli";
|
|
2557
|
+
|
|
2558
|
+
export type { RestClient };
|
|
2346
2559
|
|
|
2347
|
-
|
|
2560
|
+
/**
|
|
2561
|
+
* TODO: narrow this to the specific ${inputs.vendorName} endpoints this
|
|
2562
|
+
* plugin's capability needs (see \`@theholocron/holocron-plugin-axiom\`'s
|
|
2563
|
+
* \`rest.ts\` for the pattern: an interface with one method per endpoint,
|
|
2564
|
+
* built internally via \`createRestClient\`). Starts as an alias for the
|
|
2565
|
+
* generic client so the scaffold compiles end to end immediately.
|
|
2566
|
+
*/
|
|
2567
|
+
export type ${clientType} = RestClient;
|
|
2348
2568
|
|
|
2349
|
-
export function ${
|
|
2569
|
+
export function ${factoryName}(opts: {
|
|
2350
2570
|
\ttoken: string;
|
|
2351
2571
|
\tbaseUrl?: string;
|
|
2352
2572
|
\tfetch?: typeof fetch;
|
|
2353
|
-
}):
|
|
2573
|
+
}): ${clientType} {
|
|
2354
2574
|
\treturn createRestClient({
|
|
2355
2575
|
\t\tbaseUrl: opts.baseUrl ?? "${inputs.baseUrl}",
|
|
2356
2576
|
\t\ttoken: opts.token,
|
|
@@ -2423,7 +2643,7 @@ describe("${factoryName}", () => {
|
|
|
2423
2643
|
function render$5(inputs) {
|
|
2424
2644
|
return JSON.stringify({
|
|
2425
2645
|
display: `Holocron Plugin: ${inputs.vendorName}`,
|
|
2426
|
-
extends: "@tsconfig/node-lts
|
|
2646
|
+
extends: "@theholocron/tsconfig/node-lts",
|
|
2427
2647
|
compilerOptions: {
|
|
2428
2648
|
baseUrl: "./",
|
|
2429
2649
|
outDir: "./dist",
|
|
@@ -2431,7 +2651,7 @@ function render$5(inputs) {
|
|
|
2431
2651
|
},
|
|
2432
2652
|
include: ["src/**/*.ts"],
|
|
2433
2653
|
exclude: ["node_modules", "dist"]
|
|
2434
|
-
}, null,
|
|
2654
|
+
}, null, 2) + "\n";
|
|
2435
2655
|
}
|
|
2436
2656
|
//#endregion
|
|
2437
2657
|
//#region src/commands/plugin-create/templates/tsdown-config.ts
|
|
@@ -2547,6 +2767,7 @@ console.log("Done. Fill in capability method calls above before shipping.");
|
|
|
2547
2767
|
// ── helpers ────────────────────────────────────────────────────────
|
|
2548
2768
|
|
|
2549
2769
|
/** Wrap a capability call, print ERROR + hint on failure. */
|
|
2770
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- called once a real capability check replaces the TODO example above
|
|
2550
2771
|
async function runStep(body) {
|
|
2551
2772
|
try {
|
|
2552
2773
|
await body();
|
|
@@ -2586,7 +2807,7 @@ function hintFor(message) {
|
|
|
2586
2807
|
//#endregion
|
|
2587
2808
|
//#region src/commands/plugin-create/templates/verify-token.ts
|
|
2588
2809
|
function render$2(inputs) {
|
|
2589
|
-
const
|
|
2810
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2590
2811
|
return `/**
|
|
2591
2812
|
* \`verifyToken\` — plugin-level export used by \`holocron auth set\` +
|
|
2592
2813
|
* \`holocron auth check\`. Hits a lightweight whoami-style endpoint
|
|
@@ -2603,7 +2824,7 @@ function render$2(inputs) {
|
|
|
2603
2824
|
* \`/me\`, \`/account\`.
|
|
2604
2825
|
*/
|
|
2605
2826
|
|
|
2606
|
-
import { ${
|
|
2827
|
+
import { ${factoryName} } from "./rest.js";
|
|
2607
2828
|
|
|
2608
2829
|
export interface VerifyTokenSuccess {
|
|
2609
2830
|
ok: true;
|
|
@@ -2630,10 +2851,10 @@ export interface VerifyTokenOptions {
|
|
|
2630
2851
|
}
|
|
2631
2852
|
|
|
2632
2853
|
export async function verifyToken(token: string, opts: VerifyTokenOptions = {}): Promise<VerifyTokenResult> {
|
|
2633
|
-
const restOpts:
|
|
2854
|
+
const restOpts: { token: string; baseUrl?: string; fetch?: typeof fetch } = { token };
|
|
2634
2855
|
if (opts.baseUrl !== undefined) restOpts.baseUrl = opts.baseUrl;
|
|
2635
2856
|
if (opts.fetch !== undefined) restOpts.fetch = opts.fetch;
|
|
2636
|
-
const rest =
|
|
2857
|
+
const rest = ${factoryName}(restOpts);
|
|
2637
2858
|
try {
|
|
2638
2859
|
const me = await rest.request<MeResponse>("/me");
|
|
2639
2860
|
// Optional chaining because \`me\` is \`undefined\` on 204 / empty body.
|
|
@@ -2659,18 +2880,14 @@ describe("verifyToken", () => {
|
|
|
2659
2880
|
const stub = stubFetch([{ status: 200, body: { email: "user@example.com" } }]);
|
|
2660
2881
|
const result = await verifyToken("token", { fetch: stub.fetch });
|
|
2661
2882
|
expect(result.ok).toBe(true);
|
|
2662
|
-
|
|
2663
|
-
expect(result.subject).toMatch(/user@example.com/);
|
|
2664
|
-
}
|
|
2883
|
+
expect((result as { ok: boolean; subject?: string }).subject).toMatch(/user@example.com/);
|
|
2665
2884
|
});
|
|
2666
2885
|
|
|
2667
2886
|
it("returns ok:false with the error message on 401", async () => {
|
|
2668
2887
|
const stub = stubFetch([{ status: 401, body: { messages: ["Invalid token"] } }]);
|
|
2669
2888
|
const result = await verifyToken("bad", { fetch: stub.fetch });
|
|
2670
2889
|
expect(result.ok).toBe(false);
|
|
2671
|
-
|
|
2672
|
-
expect(result.message).toMatch(/→ 401/);
|
|
2673
|
-
}
|
|
2890
|
+
expect((result as { ok: boolean; message?: string }).message).toMatch(/→ 401/);
|
|
2674
2891
|
});
|
|
2675
2892
|
|
|
2676
2893
|
it("returns ok:false when the network layer throws", async () => {
|
|
@@ -2679,9 +2896,7 @@ describe("verifyToken", () => {
|
|
|
2679
2896
|
};
|
|
2680
2897
|
const result = await verifyToken("t", { fetch: throwing });
|
|
2681
2898
|
expect(result.ok).toBe(false);
|
|
2682
|
-
|
|
2683
|
-
expect(result.message).toMatch(/network down/);
|
|
2684
|
-
}
|
|
2899
|
+
expect((result as { ok: boolean; message?: string }).message).toMatch(/network down/);
|
|
2685
2900
|
});
|
|
2686
2901
|
});
|
|
2687
2902
|
`;
|
|
@@ -2755,7 +2970,7 @@ const TEMPLATES = [
|
|
|
2755
2970
|
render
|
|
2756
2971
|
},
|
|
2757
2972
|
{
|
|
2758
|
-
path: "eslint.config.
|
|
2973
|
+
path: "eslint.config.ts",
|
|
2759
2974
|
render: render$13
|
|
2760
2975
|
},
|
|
2761
2976
|
{
|
|
@@ -3696,10 +3911,13 @@ async function runSecretsSync(input) {
|
|
|
3696
3911
|
const rows = [];
|
|
3697
3912
|
if (loader.has("secrets")) {
|
|
3698
3913
|
const secrets = loader.get("secrets");
|
|
3699
|
-
|
|
3914
|
+
const scope = input.githubSecretScope ?? { kind: "repo" };
|
|
3915
|
+
const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
|
|
3916
|
+
print(style.step("secrets"));
|
|
3700
3917
|
for (const key of keys) {
|
|
3701
|
-
|
|
3702
|
-
|
|
3918
|
+
if (allowedKeys && !allowedKeys.has(key)) continue;
|
|
3919
|
+
rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
|
|
3920
|
+
await secrets.setSecret(scope, key, envVars[key]);
|
|
3703
3921
|
}));
|
|
3704
3922
|
print(formatRow(rows[rows.length - 1]));
|
|
3705
3923
|
}
|
|
@@ -3791,11 +4009,114 @@ function formatRow(row) {
|
|
|
3791
4009
|
if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
|
|
3792
4010
|
return ` ${style.dim(`· ${label}`)}`;
|
|
3793
4011
|
}
|
|
4012
|
+
function scopeLabel(scope) {
|
|
4013
|
+
if (scope.kind === "repo") return "scope=repo";
|
|
4014
|
+
if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
|
|
4015
|
+
return `scope=environment,env=${scope.name}`;
|
|
4016
|
+
}
|
|
3794
4017
|
function vaultProviderName(loader) {
|
|
3795
4018
|
if (!loader.has("vault")) return "<missing>";
|
|
3796
4019
|
return loader.get("vault").providerName;
|
|
3797
4020
|
}
|
|
3798
4021
|
//#endregion
|
|
4022
|
+
//#region src/commands/setup/derived-properties.ts
|
|
4023
|
+
/**
|
|
4024
|
+
* Repo archetype, in priority order:
|
|
4025
|
+
* 1. `*-template` repo name → `template` (independent of package shape).
|
|
4026
|
+
* 2. Monorepo whose primary published artifact is a CLI (some workspace
|
|
4027
|
+
* package has `bin`) → `platform` — matches `holocron` itself: the CLI is
|
|
4028
|
+
* the deliverable, the plugin packages are supporting infrastructure for
|
|
4029
|
+
* it, not separate products.
|
|
4030
|
+
* 3. Monorepo with no CLI but at least one non-private workspace package →
|
|
4031
|
+
* `library` — matches `clients`/`configs`/`utils`/`themes`/
|
|
4032
|
+
* `observability`: a family of published packages, none of them a CLI.
|
|
4033
|
+
* Checked *before* falling into the root-package branch below, because
|
|
4034
|
+
* these repos' own root `package.json` is private and often carries
|
|
4035
|
+
* `@theholocron/astro-config` for a docs site built from the monorepo
|
|
4036
|
+
* root — without this check they'd wrongly resolve to `docs` off that
|
|
4037
|
+
* root-level signal alone, when the docs site is a secondary concern of
|
|
4038
|
+
* a library collection, not what the repo fundamentally is.
|
|
4039
|
+
* 4. Single-package repo with `package.json#bin` → `cli`.
|
|
4040
|
+
* 5. Docs-only site (astro/starlight present, root package private, no
|
|
4041
|
+
* publishable exports) → `docs`.
|
|
4042
|
+
* 6. Private, non-publishable, non-docs → `app`.
|
|
4043
|
+
* 7. Otherwise, a single publishable package → `library`.
|
|
4044
|
+
*/
|
|
4045
|
+
function deriveProfile(input) {
|
|
4046
|
+
if (/-template$/.test(input.repoName)) return "template";
|
|
4047
|
+
if (input.isMonorepo) {
|
|
4048
|
+
if (input.workspacePackageJsons.some((pkg) => Boolean(pkg.bin))) return "platform";
|
|
4049
|
+
if (input.workspacePackageJsons.some((pkg) => pkg.private !== true)) return "library";
|
|
4050
|
+
}
|
|
4051
|
+
const pkg = input.rootPackageJson;
|
|
4052
|
+
if (pkg?.bin) return "cli";
|
|
4053
|
+
const deps = {
|
|
4054
|
+
...pkg?.dependencies,
|
|
4055
|
+
...pkg?.devDependencies
|
|
4056
|
+
};
|
|
4057
|
+
const isDocsSite = Boolean(deps["@theholocron/astro-config"] || deps["astro"]);
|
|
4058
|
+
if (pkg?.private && isDocsSite) return "docs";
|
|
4059
|
+
if (pkg?.private) return "app";
|
|
4060
|
+
return "library";
|
|
4061
|
+
}
|
|
4062
|
+
const KNOWN_STACK_DEPS = [
|
|
4063
|
+
"next",
|
|
4064
|
+
"vite",
|
|
4065
|
+
"astro",
|
|
4066
|
+
"tsdown",
|
|
4067
|
+
"rollup",
|
|
4068
|
+
"webpack",
|
|
4069
|
+
"storybook",
|
|
4070
|
+
"vitest",
|
|
4071
|
+
"playwright",
|
|
4072
|
+
"turbo"
|
|
4073
|
+
];
|
|
4074
|
+
/**
|
|
4075
|
+
* Detected build/framework tooling — the literal "does this repo need
|
|
4076
|
+
* next/vite/tsdown" signal `holocron.config.ts` has no field for today
|
|
4077
|
+
* (framework choice isn't a config concept, unlike `providers`/`tasks`).
|
|
4078
|
+
* Curated table, not exhaustive by design — add to `KNOWN_STACK_DEPS` as new
|
|
4079
|
+
* frameworks show up rather than trying to detect anything on npm.
|
|
4080
|
+
*/
|
|
4081
|
+
function deriveStack(pkg) {
|
|
4082
|
+
const deps = {
|
|
4083
|
+
...pkg?.dependencies,
|
|
4084
|
+
...pkg?.devDependencies
|
|
4085
|
+
};
|
|
4086
|
+
return KNOWN_STACK_DEPS.filter((name) => name in deps);
|
|
4087
|
+
}
|
|
4088
|
+
/** The provider capability keys actually wired in `providers: {}` — zero heuristics. */
|
|
4089
|
+
function deriveCapabilities(providers) {
|
|
4090
|
+
return Object.keys(providers ?? {}).sort();
|
|
4091
|
+
}
|
|
4092
|
+
/**
|
|
4093
|
+
* `source` + `ci` are the minimal baseline every repo `holocron setup`
|
|
4094
|
+
* touches wires — every `holocron.config.ts` in this org already satisfies
|
|
4095
|
+
* this, so `non-compliant` here means drift (a provider manually removed
|
|
4096
|
+
* after setup), not an unmet aspirational policy. Deliberately not a richer
|
|
4097
|
+
* per-profile policy yet (e.g. "a `library` must have `deployment`") — that's
|
|
4098
|
+
* a Phase B (GitHub App) refinement once it can post *why* on a check run.
|
|
4099
|
+
*/
|
|
4100
|
+
const REQUIRED_BASELINE = ["source", "ci"];
|
|
4101
|
+
function deriveCompliance(capabilities) {
|
|
4102
|
+
return REQUIRED_BASELINE.every((key) => capabilities.includes(key)) ? "compliant" : "non-compliant";
|
|
4103
|
+
}
|
|
4104
|
+
/**
|
|
4105
|
+
* Reads each workspace package's actual `package.json` content (not just
|
|
4106
|
+
* `readWorkspacePackages()`'s `{ slug, name, dir }` — `deriveProfile()`'s
|
|
4107
|
+
* `platform` detection needs `bin`, which that helper doesn't carry).
|
|
4108
|
+
* Missing/invalid files are skipped, matching `readWorkspacePackages()`'s
|
|
4109
|
+
* own soft-fail behavior.
|
|
4110
|
+
*/
|
|
4111
|
+
async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
|
|
4112
|
+
const results = [];
|
|
4113
|
+
for (const { slug, dir = "packages" } of workspacePackages) try {
|
|
4114
|
+
const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
|
|
4115
|
+
results.push(JSON.parse(raw));
|
|
4116
|
+
} catch {}
|
|
4117
|
+
return results;
|
|
4118
|
+
}
|
|
4119
|
+
//#endregion
|
|
3799
4120
|
//#region src/commands/setup/labels.ts
|
|
3800
4121
|
const CANONICAL_LABELS = [
|
|
3801
4122
|
{
|
|
@@ -3935,6 +4256,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
|
|
|
3935
4256
|
//#region src/templates/configs/alexjs/alexrc.json
|
|
3936
4257
|
var alexrc_default = { allow: [
|
|
3937
4258
|
"dead",
|
|
4259
|
+
"execute",
|
|
4260
|
+
"execution",
|
|
4261
|
+
"executes",
|
|
3938
4262
|
"failure",
|
|
3939
4263
|
"failures",
|
|
3940
4264
|
"hook",
|
|
@@ -3953,12 +4277,21 @@ function createIgnoreConfig$1() {
|
|
|
3953
4277
|
}
|
|
3954
4278
|
//#endregion
|
|
3955
4279
|
//#region src/templates/configs/commit-msg/commit-msg
|
|
3956
|
-
var commit_msg_default = "
|
|
4280
|
+
var commit_msg_default = "__HOLOCRON_SCRIPT__ lint commit-msg \"$1\"\n";
|
|
3957
4281
|
//#endregion
|
|
3958
4282
|
//#region src/templates/configs/commit-msg/create-config.ts
|
|
3959
4283
|
const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
|
|
3960
|
-
|
|
3961
|
-
|
|
4284
|
+
/**
|
|
4285
|
+
* `.husky/commit-msg` — lints the commit message via `holocron lint
|
|
4286
|
+
* commit-msg` (holocron#789), the real programmatic replacement for
|
|
4287
|
+
* shelling out to `commitlint --edit`. Installed by `holocron setup` when
|
|
4288
|
+
* hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
|
|
4289
|
+
*
|
|
4290
|
+
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4291
|
+
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4292
|
+
*/
|
|
4293
|
+
function createConfig$7(holocronScript = "pnpm exec holocron") {
|
|
4294
|
+
return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
3962
4295
|
}
|
|
3963
4296
|
//#endregion
|
|
3964
4297
|
//#region src/templates/configs/devmoji/create-config.ts
|
|
@@ -3974,7 +4307,7 @@ function createConfig$6() {
|
|
|
3974
4307
|
}
|
|
3975
4308
|
//#endregion
|
|
3976
4309
|
//#region src/templates/configs/editorconfig/editorconfig
|
|
3977
|
-
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";
|
|
4310
|
+
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";
|
|
3978
4311
|
//#endregion
|
|
3979
4312
|
//#region src/templates/configs/editorconfig/create-config.ts
|
|
3980
4313
|
const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
|
|
@@ -4954,7 +5287,10 @@ async function runSetup(input) {
|
|
|
4954
5287
|
print(formatStep(steps[steps.length - 1]));
|
|
4955
5288
|
if (effectivePreset && effectivePreset !== "none") {
|
|
4956
5289
|
steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
|
|
4957
|
-
await source.updateRepoSettings(
|
|
5290
|
+
await source.updateRepoSettings({
|
|
5291
|
+
...BALANCED_REPO_SETTINGS,
|
|
5292
|
+
...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
|
|
5293
|
+
});
|
|
4958
5294
|
}));
|
|
4959
5295
|
print(formatStep(steps[steps.length - 1]));
|
|
4960
5296
|
const tasksConfig = { tasks: config.tasks };
|
|
@@ -5061,7 +5397,7 @@ async function runSetup(input) {
|
|
|
5061
5397
|
}));
|
|
5062
5398
|
print(formatStep(steps[steps.length - 1]));
|
|
5063
5399
|
steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
|
|
5064
|
-
await source.writeRepoFile(".husky/commit-msg", createConfig$7());
|
|
5400
|
+
await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
|
|
5065
5401
|
}));
|
|
5066
5402
|
print(formatStep(steps[steps.length - 1]));
|
|
5067
5403
|
if (hooksEnabled) {
|
|
@@ -5135,13 +5471,20 @@ async function runSetup(input) {
|
|
|
5135
5471
|
await source.writeRepoFile("turbo.json", content);
|
|
5136
5472
|
}));
|
|
5137
5473
|
print(formatStep(steps[steps.length - 1]));
|
|
5138
|
-
const
|
|
5474
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
5475
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
5139
5476
|
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
5140
|
-
const rootScripts =
|
|
5477
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
5141
5478
|
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
5142
5479
|
if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
5143
5480
|
await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
|
|
5144
5481
|
}));
|
|
5482
|
+
if (rootPkgRaw) {
|
|
5483
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
5484
|
+
if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
|
|
5485
|
+
await source.writeRepoFile("package.json", depFix.content);
|
|
5486
|
+
}));
|
|
5487
|
+
}
|
|
5145
5488
|
}
|
|
5146
5489
|
print(formatStep(steps[steps.length - 1]));
|
|
5147
5490
|
}
|
|
@@ -5152,7 +5495,7 @@ async function runSetup(input) {
|
|
|
5152
5495
|
print(formatStep(steps[steps.length - 1]));
|
|
5153
5496
|
}
|
|
5154
5497
|
const properties = {};
|
|
5155
|
-
if (effectivePreset && effectivePreset !== "none") properties["
|
|
5498
|
+
if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
|
|
5156
5499
|
const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
|
|
5157
5500
|
properties["monorepo"] = String(isMonorepo);
|
|
5158
5501
|
const manual = repo?.properties ?? {};
|
|
@@ -5160,6 +5503,21 @@ async function runSetup(input) {
|
|
|
5160
5503
|
if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
|
|
5161
5504
|
if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
|
|
5162
5505
|
if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
|
|
5506
|
+
{
|
|
5507
|
+
const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
5508
|
+
const workspacePackages = readWorkspacePackages(input.context.repoRoot);
|
|
5509
|
+
const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
|
|
5510
|
+
properties["holocron_profile"] = deriveProfile({
|
|
5511
|
+
rootPackageJson,
|
|
5512
|
+
repoName: basename(input.context.repoRoot),
|
|
5513
|
+
isMonorepo,
|
|
5514
|
+
workspacePackageJsons
|
|
5515
|
+
});
|
|
5516
|
+
properties["holocron_stack"] = deriveStack(rootPackageJson);
|
|
5517
|
+
const capabilities = deriveCapabilities(config.providers);
|
|
5518
|
+
properties["holocron_capabilities"] = capabilities;
|
|
5519
|
+
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
5520
|
+
}
|
|
5163
5521
|
if (source.syncProperties) {
|
|
5164
5522
|
steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
|
|
5165
5523
|
print(formatStep(steps[steps.length - 1]));
|
|
@@ -5178,34 +5536,36 @@ async function runSetup(input) {
|
|
|
5178
5536
|
print(formatStep(steps[steps.length - 1]));
|
|
5179
5537
|
}
|
|
5180
5538
|
const teams = repo?.teams ?? [];
|
|
5181
|
-
if (teams.length > 0)
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5539
|
+
if (teams.length > 0) {
|
|
5540
|
+
if (source.syncTeams) {
|
|
5541
|
+
steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
|
|
5542
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5543
|
+
const repoCoord = input.context.repo ?? repo?.name ?? "";
|
|
5544
|
+
const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
|
|
5545
|
+
const writeableTeams = teams.map((t) => typeof t === "string" ? {
|
|
5546
|
+
slug: t,
|
|
5547
|
+
permission: "push"
|
|
5548
|
+
} : t).filter((t) => [
|
|
5549
|
+
"push",
|
|
5550
|
+
"maintain",
|
|
5551
|
+
"admin"
|
|
5552
|
+
].includes(t.permission));
|
|
5553
|
+
if (org && writeableTeams.length > 0) {
|
|
5554
|
+
steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
|
|
5555
|
+
const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
|
|
5556
|
+
await source.writeRepoFile(".github/CODEOWNERS", content);
|
|
5557
|
+
}));
|
|
5558
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5559
|
+
}
|
|
5560
|
+
} else {
|
|
5561
|
+
steps.push({
|
|
5562
|
+
capability: "source",
|
|
5563
|
+
step: "sync teams",
|
|
5564
|
+
status: "skip",
|
|
5565
|
+
message: "provider does not implement syncTeams"
|
|
5566
|
+
});
|
|
5199
5567
|
print(formatStep(steps[steps.length - 1]));
|
|
5200
5568
|
}
|
|
5201
|
-
} else {
|
|
5202
|
-
steps.push({
|
|
5203
|
-
capability: "source",
|
|
5204
|
-
step: "sync teams",
|
|
5205
|
-
status: "skip",
|
|
5206
|
-
message: "provider does not implement syncTeams"
|
|
5207
|
-
});
|
|
5208
|
-
print(formatStep(steps[steps.length - 1]));
|
|
5209
5569
|
}
|
|
5210
5570
|
}
|
|
5211
5571
|
if (loader.has("environments")) {
|
|
@@ -5261,11 +5621,9 @@ async function runSetup(input) {
|
|
|
5261
5621
|
const wikiProxy = wiki.proxyConfig?.();
|
|
5262
5622
|
if (wikiDns && loader.has("dns")) {
|
|
5263
5623
|
const dns = loader.get("dns");
|
|
5264
|
-
steps.push(await runStep("dns", `upsertRecord ${wikiDns.
|
|
5624
|
+
steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
|
|
5265
5625
|
await dns.upsertRecord(wikiDns.zone, {
|
|
5266
|
-
|
|
5267
|
-
name: wikiDns.cname,
|
|
5268
|
-
content: wikiDns.target,
|
|
5626
|
+
...wikiDns.record,
|
|
5269
5627
|
ttl: 1,
|
|
5270
5628
|
...wikiProxy ? { proxied: true } : {}
|
|
5271
5629
|
});
|
|
@@ -5274,8 +5632,8 @@ async function runSetup(input) {
|
|
|
5274
5632
|
}
|
|
5275
5633
|
if (wikiProxy && wikiDns && loader.has("workers")) {
|
|
5276
5634
|
const workers = loader.get("workers");
|
|
5277
|
-
steps.push(await runStep("workers", `upsertProxy ${wikiDns.
|
|
5278
|
-
await workers.upsertProxy(wikiDns.
|
|
5635
|
+
steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
|
|
5636
|
+
await workers.upsertProxy(wikiDns.record.name, wikiProxy);
|
|
5279
5637
|
}));
|
|
5280
5638
|
print(formatStep(steps[steps.length - 1]));
|
|
5281
5639
|
}
|
|
@@ -5288,11 +5646,31 @@ async function runSetup(input) {
|
|
|
5288
5646
|
org: config.org,
|
|
5289
5647
|
domain: config.domain
|
|
5290
5648
|
}) : null;
|
|
5649
|
+
const ensureCustomDomain = async (projectId, hostname) => {
|
|
5650
|
+
if (!deploy.ensureCustomDomain) return;
|
|
5651
|
+
const customDomainDns = { value: null };
|
|
5652
|
+
steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
|
|
5653
|
+
customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
|
|
5654
|
+
}));
|
|
5655
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5656
|
+
if (customDomainDns.value && loader.has("dns")) {
|
|
5657
|
+
const dns = loader.get("dns");
|
|
5658
|
+
const dnsRecord = customDomainDns.value;
|
|
5659
|
+
steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
|
|
5660
|
+
await dns.upsertRecord(dnsRecord.zone, {
|
|
5661
|
+
...dnsRecord.record,
|
|
5662
|
+
ttl: 1
|
|
5663
|
+
});
|
|
5664
|
+
}));
|
|
5665
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5666
|
+
}
|
|
5667
|
+
};
|
|
5291
5668
|
if (!previewCfg) {
|
|
5292
5669
|
steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
|
|
5293
5670
|
await deploy.ensureProject({ name: config.name });
|
|
5294
5671
|
}));
|
|
5295
5672
|
print(formatStep(steps[steps.length - 1]));
|
|
5673
|
+
if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
|
|
5296
5674
|
}
|
|
5297
5675
|
if (previewCfg) {
|
|
5298
5676
|
steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
|
|
@@ -5300,13 +5678,8 @@ async function runSetup(input) {
|
|
|
5300
5678
|
}));
|
|
5301
5679
|
print(formatStep(steps[steps.length - 1]));
|
|
5302
5680
|
}
|
|
5303
|
-
if (previewCfg?.domain
|
|
5304
|
-
|
|
5305
|
-
await deploy.ensureCustomDomain(previewCfg.project, previewCfg.domain);
|
|
5306
|
-
}));
|
|
5307
|
-
print(formatStep(steps[steps.length - 1]));
|
|
5308
|
-
}
|
|
5309
|
-
if (previewCfg?.domain && loader.has("dns")) {
|
|
5681
|
+
if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
|
|
5682
|
+
if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
|
|
5310
5683
|
const dns = loader.get("dns");
|
|
5311
5684
|
const wildcardDomain = `*.${previewCfg.domain}`;
|
|
5312
5685
|
steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
|
|
@@ -5754,11 +6127,13 @@ async function runSyncReadme(input) {
|
|
|
5754
6127
|
}
|
|
5755
6128
|
const description = loaded.resolved.description ?? "";
|
|
5756
6129
|
const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
|
|
6130
|
+
const scripts = pkg.scripts ?? {};
|
|
6131
|
+
const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
|
|
5757
6132
|
const sections = generateReadme({
|
|
5758
6133
|
description,
|
|
5759
6134
|
homepage,
|
|
5760
|
-
scripts
|
|
5761
|
-
packages
|
|
6135
|
+
scripts,
|
|
6136
|
+
packages
|
|
5762
6137
|
});
|
|
5763
6138
|
const markerSections = {
|
|
5764
6139
|
...sections.packages !== void 0 && { packages: sections.packages },
|
|
@@ -6000,7 +6375,7 @@ async function runSyncWiki(input) {
|
|
|
6000
6375
|
capability: "local",
|
|
6001
6376
|
step: "sync wiki",
|
|
6002
6377
|
status: "fail",
|
|
6003
|
-
message: err instanceof Error ? err.message :
|
|
6378
|
+
message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
|
|
6004
6379
|
};
|
|
6005
6380
|
}
|
|
6006
6381
|
}
|
|
@@ -6062,40 +6437,59 @@ async function runSync(input) {
|
|
|
6062
6437
|
for (const stepName of SYNC_STEPS) {
|
|
6063
6438
|
if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
|
|
6064
6439
|
if (LOCAL_STEPS.has(stepName)) continue;
|
|
6065
|
-
if (stepName === "labels")
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6440
|
+
if (stepName === "labels") {
|
|
6441
|
+
if (source.syncLabels) {
|
|
6442
|
+
steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
|
|
6443
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6444
|
+
} else {
|
|
6445
|
+
steps.push({
|
|
6446
|
+
capability: "source",
|
|
6447
|
+
step: "sync labels",
|
|
6448
|
+
status: "skip",
|
|
6449
|
+
message: "provider does not implement syncLabels"
|
|
6450
|
+
});
|
|
6451
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6452
|
+
}
|
|
6076
6453
|
}
|
|
6077
|
-
if (stepName === "properties")
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6454
|
+
if (stepName === "properties") {
|
|
6455
|
+
if (source.syncProperties) {
|
|
6456
|
+
const repo = config.repo;
|
|
6457
|
+
const properties = {};
|
|
6458
|
+
const effectivePreset = repo?.protection;
|
|
6459
|
+
if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
|
|
6460
|
+
const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
|
|
6461
|
+
properties["monorepo"] = String(isMonorepo);
|
|
6462
|
+
const manual = repo?.properties ?? {};
|
|
6463
|
+
if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
|
|
6464
|
+
if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
|
|
6465
|
+
if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
|
|
6466
|
+
if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
|
|
6467
|
+
{
|
|
6468
|
+
const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
6469
|
+
const workspacePackages = readWorkspacePackages(input.context.repoRoot);
|
|
6470
|
+
const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
|
|
6471
|
+
properties["holocron_profile"] = deriveProfile({
|
|
6472
|
+
rootPackageJson,
|
|
6473
|
+
repoName: basename(input.context.repoRoot),
|
|
6474
|
+
isMonorepo,
|
|
6475
|
+
workspacePackageJsons
|
|
6476
|
+
});
|
|
6477
|
+
properties["holocron_stack"] = deriveStack(rootPackageJson);
|
|
6478
|
+
const capabilities = deriveCapabilities(config.providers);
|
|
6479
|
+
properties["holocron_capabilities"] = capabilities;
|
|
6480
|
+
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
6481
|
+
}
|
|
6482
|
+
steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
|
|
6483
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6484
|
+
} else {
|
|
6485
|
+
steps.push({
|
|
6486
|
+
capability: "source",
|
|
6487
|
+
step: "sync properties",
|
|
6488
|
+
status: "skip",
|
|
6489
|
+
message: "provider does not implement syncProperties"
|
|
6490
|
+
});
|
|
6491
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6492
|
+
}
|
|
6099
6493
|
}
|
|
6100
6494
|
if (stepName === "teams") {
|
|
6101
6495
|
const teams = config.repo?.teams ?? [];
|
|
@@ -6307,29 +6701,34 @@ async function runSync(input) {
|
|
|
6307
6701
|
}
|
|
6308
6702
|
}
|
|
6309
6703
|
}
|
|
6310
|
-
if (stepName === "scripts")
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
const
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
6332
|
-
|
|
6704
|
+
if (stepName === "scripts") {
|
|
6705
|
+
if (config.syncScripts === false) {
|
|
6706
|
+
steps.push({
|
|
6707
|
+
capability: "local",
|
|
6708
|
+
step: "sync scripts",
|
|
6709
|
+
status: "skip",
|
|
6710
|
+
message: "syncScripts: false"
|
|
6711
|
+
});
|
|
6712
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6713
|
+
} else {
|
|
6714
|
+
const tasksConfig = { tasks: config.tasks };
|
|
6715
|
+
if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
|
|
6716
|
+
const astromechScripts = createAstromech({
|
|
6717
|
+
cwd: input.context.repoRoot,
|
|
6718
|
+
config: tasksConfig,
|
|
6719
|
+
logger
|
|
6720
|
+
});
|
|
6721
|
+
const desired = astromechScripts.packageScripts();
|
|
6722
|
+
const turboContent = astromechScripts.turboConfig();
|
|
6723
|
+
const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
|
|
6724
|
+
steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
|
|
6725
|
+
const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
|
|
6726
|
+
if (changed === null) return "no package.json";
|
|
6727
|
+
if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
|
|
6728
|
+
return `${changed.join(", ")} set`;
|
|
6729
|
+
}));
|
|
6730
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6731
|
+
}
|
|
6333
6732
|
}
|
|
6334
6733
|
if (stepName === "turbo") {
|
|
6335
6734
|
const astromechTurbo = createAstromech({
|
|
@@ -6351,9 +6750,10 @@ async function runSync(input) {
|
|
|
6351
6750
|
await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
|
|
6352
6751
|
}));
|
|
6353
6752
|
print(formatSyncStep(steps[steps.length - 1]));
|
|
6354
|
-
const
|
|
6753
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
6754
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
6355
6755
|
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
6356
|
-
const rootScripts =
|
|
6756
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
6357
6757
|
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
6358
6758
|
if (workspaceFix.changed) {
|
|
6359
6759
|
steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
@@ -6361,6 +6761,15 @@ async function runSync(input) {
|
|
|
6361
6761
|
}));
|
|
6362
6762
|
print(formatSyncStep(steps[steps.length - 1]));
|
|
6363
6763
|
}
|
|
6764
|
+
if (rootPkgRaw) {
|
|
6765
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
6766
|
+
if (depFix.changed) {
|
|
6767
|
+
steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
|
|
6768
|
+
await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
|
|
6769
|
+
}));
|
|
6770
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6771
|
+
}
|
|
6772
|
+
}
|
|
6364
6773
|
}
|
|
6365
6774
|
}
|
|
6366
6775
|
if (stepName === "wiki") {
|
|
@@ -6444,8 +6853,21 @@ async function writePackageJsonField(repoRoot, field, value) {
|
|
|
6444
6853
|
* Merge `desired` script entries into `package.json#scripts` — sets only the
|
|
6445
6854
|
* listed keys, never touches the rest. Returns the sorted list of keys that
|
|
6446
6855
|
* drifted (empty when all current), or `null` when there is no `package.json`.
|
|
6856
|
+
*
|
|
6857
|
+
* `turboEligible` (#747): task names `turbo.json` fans out — the same set
|
|
6858
|
+
* {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
|
|
6859
|
+
* only, an *existing* script that isn't the generated `holocron run <task>
|
|
6860
|
+
* --` wrapper is left untouched: root may genuinely be a directly-buildable
|
|
6861
|
+
* package (not an orchestrator) with its own real command (`tsc --noEmit`,
|
|
6862
|
+
* `vitest run`, …) for that task. Overwriting it would silently discard the
|
|
6863
|
+
* real command and, once `turbo.json` exists and root is a real workspace
|
|
6864
|
+
* member (#692), reintroduce infinite recursion — `turbo run <task>` →
|
|
6865
|
+
* root's own `<task>` script → the wrapper → `holocron run <task>` sees
|
|
6866
|
+
* `turbo.json` still defines the task → `turbo run <task>` again, forever.
|
|
6867
|
+
* Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
|
|
6868
|
+
* legitimate direct-command alternative and stays fully generator-owned.
|
|
6447
6869
|
*/
|
|
6448
|
-
async function mergePackageJsonScripts(repoRoot, desired) {
|
|
6870
|
+
async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
|
|
6449
6871
|
const pkgPath = join(repoRoot, "package.json");
|
|
6450
6872
|
let content;
|
|
6451
6873
|
try {
|
|
@@ -6456,15 +6878,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
|
|
|
6456
6878
|
const pkg = JSON.parse(content);
|
|
6457
6879
|
const scripts = pkg.scripts ?? {};
|
|
6458
6880
|
const changed = [];
|
|
6459
|
-
for (const [name, command] of Object.entries(desired))
|
|
6460
|
-
scripts[name]
|
|
6461
|
-
|
|
6881
|
+
for (const [name, command] of Object.entries(desired)) {
|
|
6882
|
+
const existing = scripts[name];
|
|
6883
|
+
if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
|
|
6884
|
+
if (existing !== command) {
|
|
6885
|
+
scripts[name] = command;
|
|
6886
|
+
changed.push(name);
|
|
6887
|
+
}
|
|
6462
6888
|
}
|
|
6463
6889
|
if (changed.length === 0) return [];
|
|
6464
6890
|
pkg.scripts = scripts;
|
|
6465
6891
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
6466
6892
|
return changed.sort();
|
|
6467
6893
|
}
|
|
6894
|
+
/**
|
|
6895
|
+
* Matches the exact shape `packageScripts()` generates for `taskName` —
|
|
6896
|
+
* `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
|
|
6897
|
+
* (`holocronScript`, itself allowed to be multi-word, e.g. `"node
|
|
6898
|
+
packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
|
|
6899
|
+
* rather than the whole string means an old wrapper survives a
|
|
6900
|
+
* `holocronScript` rename intact — it's still recognized as a wrapper (safe
|
|
6901
|
+
* to update to the new one), not mistaken for a real command.
|
|
6902
|
+
*/
|
|
6903
|
+
function isGeneratedRunWrapper(command, taskName) {
|
|
6904
|
+
return command.endsWith(` run ${taskName} --`);
|
|
6905
|
+
}
|
|
6468
6906
|
const README_DESC_START = "<!-- holocron:description -->";
|
|
6469
6907
|
const README_DESC_END = "<!-- /holocron:description -->";
|
|
6470
6908
|
async function updateReadmeDescription(repoRoot, description) {
|
|
@@ -7331,6 +7769,63 @@ async function runUpgradeNode(input) {
|
|
|
7331
7769
|
};
|
|
7332
7770
|
}
|
|
7333
7771
|
//#endregion
|
|
7772
|
+
//#region src/commit-lint/lint-message.ts
|
|
7773
|
+
/**
|
|
7774
|
+
* Lints one commit message string against an already-loaded commitlint
|
|
7775
|
+
* config. The genuinely environment-independent half of commit-message
|
|
7776
|
+
* linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
|
|
7777
|
+
* caller (Sentinel isolates itself from any ambient config since it runs
|
|
7778
|
+
* standalone, away from a real repo checkout; `lintCommitMsgFile` below
|
|
7779
|
+
* just lets cosmiconfig discover the invoking repo's own
|
|
7780
|
+
* `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
|
|
7781
|
+
* but linting one message against a loaded config is identical either way.
|
|
7782
|
+
*
|
|
7783
|
+
* Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
|
|
7784
|
+
* itself uses internally — never a reimplementation of commitlint's rules.
|
|
7785
|
+
*/
|
|
7786
|
+
/** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
|
|
7787
|
+
function selectParserOpts(parserPreset) {
|
|
7788
|
+
return parserPreset?.parserOpts;
|
|
7789
|
+
}
|
|
7790
|
+
async function lintCommitMessage(message, loaded) {
|
|
7791
|
+
const opts = {
|
|
7792
|
+
/* istanbul ignore next -- see comment above */
|
|
7793
|
+
parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
|
|
7794
|
+
plugins: loaded.plugins,
|
|
7795
|
+
/* istanbul ignore next -- see comment above */
|
|
7796
|
+
ignores: loaded.ignores ?? [],
|
|
7797
|
+
defaultIgnores: loaded.defaultIgnores !== false
|
|
7798
|
+
};
|
|
7799
|
+
const outcome = await lint(message, loaded.rules, opts);
|
|
7800
|
+
if (outcome.valid) return [];
|
|
7801
|
+
return outcome.errors.map((error) => ({
|
|
7802
|
+
rule: error.name,
|
|
7803
|
+
message: error.message
|
|
7804
|
+
}));
|
|
7805
|
+
}
|
|
7806
|
+
//#endregion
|
|
7807
|
+
//#region src/commit-lint/lint-commit-msg-file.ts
|
|
7808
|
+
/**
|
|
7809
|
+
* `holocron lint commit-msg <file>` — a real programmatic replacement for
|
|
7810
|
+
* shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
|
|
7811
|
+
* (holocron#789). Matches that CLI's own contract exactly: `file` is a
|
|
7812
|
+
* path to the commit message being written (the arg git's `commit-msg`
|
|
7813
|
+
* hook passes as `$1`), no `--config` override needed — `@commitlint/load`
|
|
7814
|
+
* runs from `cwd` with no seed, so cosmiconfig's own normal upward search
|
|
7815
|
+
* discovers the invoking repo's real `commitlint.config.*` file, exactly
|
|
7816
|
+
* as `commitlint --edit` (no `--config` flag) already does today. Unlike
|
|
7817
|
+
* Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
|
|
7818
|
+
* runs inside a real repo checkout with a real `node_modules`, not
|
|
7819
|
+
* standalone on a server.
|
|
7820
|
+
*/
|
|
7821
|
+
async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
|
|
7822
|
+
const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
|
|
7823
|
+
return {
|
|
7824
|
+
valid: violations.length === 0,
|
|
7825
|
+
violations
|
|
7826
|
+
};
|
|
7827
|
+
}
|
|
7828
|
+
//#endregion
|
|
7334
7829
|
//#region src/config/load-config.ts
|
|
7335
7830
|
/**
|
|
7336
7831
|
* `holocron.config.{ts,js,mjs,cjs,json}` loader.
|
|
@@ -7441,7 +7936,9 @@ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
|
7441
7936
|
* The full command surface, hand-maintained in parallel with `cli.ts`'s
|
|
7442
7937
|
* yargs registrations (this module does not introspect yargs at runtime).
|
|
7443
7938
|
* `run <task>` is deliberately excluded — it's CI/scripting-oriented, and an
|
|
7444
|
-
* interactive prompt in front of it would work against that.
|
|
7939
|
+
* interactive prompt in front of it would work against that. `lint
|
|
7940
|
+
* commit-msg <file>` is excluded for the same reason: it's invoked
|
|
7941
|
+
* exclusively from `.husky/commit-msg "$1"`, never typed by a human.
|
|
7445
7942
|
*/
|
|
7446
7943
|
const COMMAND_REGISTRY = [
|
|
7447
7944
|
{
|
|
@@ -7752,7 +8249,7 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
|
|
|
7752
8249
|
//#endregion
|
|
7753
8250
|
//#region src/update-notifier.ts
|
|
7754
8251
|
const PACKAGE_NAME = "@theholocron/cli";
|
|
7755
|
-
const CACHE_TTL_MS =
|
|
8252
|
+
const CACHE_TTL_MS = 864e5;
|
|
7756
8253
|
const FETCH_TIMEOUT_MS = 3e3;
|
|
7757
8254
|
function getCacheDir() {
|
|
7758
8255
|
return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
|
|
@@ -8118,9 +8615,17 @@ try {
|
|
|
8118
8615
|
type: "array",
|
|
8119
8616
|
default: ["production", "preview"],
|
|
8120
8617
|
describe: "Deployment targets to sync to. Defaults to production + preview."
|
|
8618
|
+
}).option("github-secret", {
|
|
8619
|
+
type: "array",
|
|
8620
|
+
describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
|
|
8621
|
+
}).option("github-secret-scope", {
|
|
8622
|
+
type: "string",
|
|
8623
|
+
default: "repo",
|
|
8624
|
+
describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
|
|
8121
8625
|
}), async (argv) => {
|
|
8122
8626
|
const tokens = tokenContext(argv.token);
|
|
8123
8627
|
if (!tokens) return;
|
|
8628
|
+
const githubSecretScope = parseScope(argv.githubSecretScope);
|
|
8124
8629
|
const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
|
|
8125
8630
|
const loaded = await loadConfig(argv.cwd);
|
|
8126
8631
|
applyResolvedConfig(argv, loaded.resolved);
|
|
@@ -8134,11 +8639,13 @@ try {
|
|
|
8134
8639
|
},
|
|
8135
8640
|
environmentId,
|
|
8136
8641
|
...argv.projectId ? { projectId: argv.projectId } : {},
|
|
8642
|
+
...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
|
|
8643
|
+
githubSecretScope,
|
|
8137
8644
|
targets: argv.target
|
|
8138
8645
|
})).summary.fail > 0) process.exitCode = 1;
|
|
8139
8646
|
}).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
|
|
8140
8647
|
type: "string",
|
|
8141
|
-
describe: "Git branch to deploy"
|
|
8648
|
+
describe: "Git branch to deploy (omit when using --files)"
|
|
8142
8649
|
}).option("project-id", {
|
|
8143
8650
|
type: "string",
|
|
8144
8651
|
demandOption: true,
|
|
@@ -8147,20 +8654,34 @@ try {
|
|
|
8147
8654
|
type: "string",
|
|
8148
8655
|
choices: ["production", "staging"],
|
|
8149
8656
|
describe: "Named environment to deploy into. Omit for a branch preview."
|
|
8150
|
-
}),
|
|
8657
|
+
}).option("files", {
|
|
8658
|
+
type: "string",
|
|
8659
|
+
describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
|
|
8660
|
+
}).conflicts("files", "branch"), async (argv) => {
|
|
8151
8661
|
const tokens = tokenContext(argv.token);
|
|
8152
8662
|
if (!tokens) return;
|
|
8153
|
-
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
8154
8663
|
const loaded = await loadConfig(argv.cwd);
|
|
8155
8664
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8665
|
+
const context = {
|
|
8666
|
+
repoRoot: argv.cwd,
|
|
8667
|
+
dryRun: argv.dryRun,
|
|
8668
|
+
...tokens,
|
|
8669
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
8670
|
+
};
|
|
8671
|
+
if (argv.files) {
|
|
8672
|
+
if ((await runDeployFromFiles({
|
|
8673
|
+
loaded,
|
|
8674
|
+
context,
|
|
8675
|
+
projectId: argv.projectId,
|
|
8676
|
+
dir: argv.files,
|
|
8677
|
+
...argv.target ? { target: argv.target } : {}
|
|
8678
|
+
})).status === "fail") process.exitCode = 1;
|
|
8679
|
+
return;
|
|
8680
|
+
}
|
|
8681
|
+
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
8156
8682
|
if ((await runDeploy({
|
|
8157
8683
|
loaded,
|
|
8158
|
-
context
|
|
8159
|
-
repoRoot: argv.cwd,
|
|
8160
|
-
dryRun: argv.dryRun,
|
|
8161
|
-
...tokens,
|
|
8162
|
-
org: resolveOrg(argv, loaded.resolved)
|
|
8163
|
-
},
|
|
8684
|
+
context,
|
|
8164
8685
|
projectId: argv.projectId,
|
|
8165
8686
|
branch,
|
|
8166
8687
|
...argv.target ? { target: argv.target } : {}
|
|
@@ -8336,7 +8857,36 @@ try {
|
|
|
8336
8857
|
scope: argv.all ? "all" : "required",
|
|
8337
8858
|
...argv.filter ? { filter: argv.filter } : {}
|
|
8338
8859
|
}).status === "fail") process.exitCode = 1;
|
|
8339
|
-
}).command("
|
|
8860
|
+
}).command("deploy-on-release", "Deploy every workspace package whose delivery.deploy task is `on: release` — run by the delivery.publish workflow's deploy job", (y) => y.option("channel", {
|
|
8861
|
+
type: "string",
|
|
8862
|
+
default: "",
|
|
8863
|
+
describe: "The release's channel: its prerelease identifier (`alpha`); empty or `main` for a stable release."
|
|
8864
|
+
}).option("from", {
|
|
8865
|
+
type: "string",
|
|
8866
|
+
describe: "The previous release's commit, to diff against. Omit when there is none."
|
|
8867
|
+
}).option("to", {
|
|
8868
|
+
type: "string",
|
|
8869
|
+
describe: "The release commit. Default HEAD."
|
|
8870
|
+
}), async (argv) => {
|
|
8871
|
+
if ((await deployOnRelease({
|
|
8872
|
+
cwd: argv.cwd,
|
|
8873
|
+
channel: argv.channel,
|
|
8874
|
+
...argv.from ? { from: argv.from } : {},
|
|
8875
|
+
...argv.to ? { to: argv.to } : {},
|
|
8876
|
+
dryRun: argv.dryRun,
|
|
8877
|
+
print: (line) => console.log(line)
|
|
8878
|
+
})).status === "fail") process.exitCode = 1;
|
|
8879
|
+
}).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", {
|
|
8880
|
+
type: "string",
|
|
8881
|
+
demandOption: true,
|
|
8882
|
+
describe: "Path to the commit message file (git's commit-msg hook passes this as $1)."
|
|
8883
|
+
}), async (argv) => {
|
|
8884
|
+
const result = await lintCommitMsgFile(argv.file, argv.cwd);
|
|
8885
|
+
if (!result.valid) {
|
|
8886
|
+
for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
|
|
8887
|
+
process.exitCode = 1;
|
|
8888
|
+
}
|
|
8889
|
+
}), () => {}).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
|
|
8340
8890
|
type: "string",
|
|
8341
8891
|
default: "theholocron/.github",
|
|
8342
8892
|
describe: "Target org/repo (default: theholocron/.github)"
|