@theholocron/cli 3.34.5 → 3.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities/index.d.mts +31 -1
- package/dist/cli.mjs +239 -99
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/package.json +3 -3
|
@@ -157,6 +157,14 @@ interface Source extends ProviderIdentity {
|
|
|
157
157
|
* Optional — providers that don't support setting a homepage omit this.
|
|
158
158
|
*/
|
|
159
159
|
syncHomepage?(homepage: string): Promise<string>;
|
|
160
|
+
/**
|
|
161
|
+
* Fetch a pull request by number. The `repo` override lets callers look up
|
|
162
|
+
* a PR in a different repo than the one the plugin is scoped to — useful
|
|
163
|
+
* when the cleanup command targets another repo's PR (e.g. docs PR from
|
|
164
|
+
* the holocron repo context).
|
|
165
|
+
* Optional — providers without a PR concept omit this.
|
|
166
|
+
*/
|
|
167
|
+
getPullRequest?(number: number, repo?: string): Promise<PullRequest>;
|
|
160
168
|
/**
|
|
161
169
|
* Enable or update GitHub Pages for the repository.
|
|
162
170
|
* Idempotent: POST to create, PUT to update existing settings.
|
|
@@ -178,6 +186,17 @@ interface PagesConfig {
|
|
|
178
186
|
/** Enforce HTTPS. Only effective once the custom domain is DNS-verified. */
|
|
179
187
|
https?: boolean;
|
|
180
188
|
}
|
|
189
|
+
interface PullRequest {
|
|
190
|
+
number: number;
|
|
191
|
+
title: string;
|
|
192
|
+
/** "open" | "closed" — GitHub marks merged PRs as "closed". */
|
|
193
|
+
state: "open" | "closed";
|
|
194
|
+
/** True when the PR was merged (as opposed to closed without merging). */
|
|
195
|
+
merged: boolean;
|
|
196
|
+
/** The head branch name (e.g. "fix/my-change"). */
|
|
197
|
+
branch: string;
|
|
198
|
+
url: string;
|
|
199
|
+
}
|
|
181
200
|
type CiRunStatus = "queued" | "in_progress" | "completed" | "cancelled" | "failure" | "success" | "skipped";
|
|
182
201
|
interface CiRun {
|
|
183
202
|
id: string | number;
|
|
@@ -340,6 +359,7 @@ interface DeploymentRecord {
|
|
|
340
359
|
/** Named environment if one was targeted; undefined for branch previews. */
|
|
341
360
|
target?: DeploymentTrigger;
|
|
342
361
|
status: "queued" | "building" | "ready" | "error" | "cancelled";
|
|
362
|
+
createdAt?: string;
|
|
343
363
|
}
|
|
344
364
|
interface Deployment extends ProviderIdentity {
|
|
345
365
|
readonly key: "deployment";
|
|
@@ -366,6 +386,16 @@ interface Deployment extends ProviderIdentity {
|
|
|
366
386
|
target?: DeploymentTrigger;
|
|
367
387
|
}): Promise<DeploymentRecord>;
|
|
368
388
|
getDeployment(deploymentId: string): Promise<DeploymentRecord>;
|
|
389
|
+
/**
|
|
390
|
+
* List preview deployments for a specific branch alias (e.g. "repo-pr-42").
|
|
391
|
+
* Optional — providers that don't support listing by branch omit this.
|
|
392
|
+
*/
|
|
393
|
+
listPreviewDeployments?(projectId: string, branch: string): Promise<DeploymentRecord[]>;
|
|
394
|
+
/**
|
|
395
|
+
* Delete specific preview deployments by id. Returns the count deleted.
|
|
396
|
+
* Optional — providers that don't support deletion omit this.
|
|
397
|
+
*/
|
|
398
|
+
deletePreviewDeployments?(projectId: string, deploymentIds: string[]): Promise<number>;
|
|
369
399
|
/**
|
|
370
400
|
* Add a custom domain (or wildcard) to the project. Idempotent — no-op
|
|
371
401
|
* when the domain is already present. Optional: providers without a custom
|
|
@@ -616,4 +646,4 @@ type CardinalityFor<K extends CapabilityKey> = (typeof CARDINALITY)[K];
|
|
|
616
646
|
type ResolvedCapability<K extends CapabilityKey> = CardinalityFor<K> extends "many" ? CapabilityImpls[K][] : CapabilityImpls[K];
|
|
617
647
|
declare function isMulti<K extends CapabilityKey>(key: K): CardinalityFor<K> extends "many" ? true : false;
|
|
618
648
|
//#endregion
|
|
619
|
-
export { Analytics, Auth, AuthDescription, AuthEvent, AuthEventType, AuthIdentity, AuthUser, CARDINALITY, CapabilityImpls, CapabilityKey, Cardinality, CardinalityFor, Ci, CiRun, CiRunFilter, CiRunStatus, ConnectionStringOptions, CreateAuthUserInput, Deployment, DeploymentProject, DeploymentProjectSettings, DeploymentRecord, DeploymentTarget, DeploymentTrigger, Dns, DnsRecord, DnsRecordType, EnsureResult, Environment, EnvironmentReviewer, Environments, Issue, IssueSearchFilter, Issues, LabelDef, LifecycleResult, LifecycleSlot, NormalizedAuthUser, Notifications, Observability, PagesConfig, ParseWebhookInput, ProviderApiError, ProviderIdentity, REQUIRED_CAPABILITIES, RepoRef, RepoSettings, ResolvedCapability, Ruleset, SecretScope, Secrets, Source, StatusCategory, Storage, StorageBranch, TeamEntry, TeamPermission, Tooling, ToolingDoctorReport, TrackerDoctorReport, TrackerUser, Vault, WebhookDashboardInfo, WebhookVerificationError, isMulti };
|
|
649
|
+
export { Analytics, Auth, AuthDescription, AuthEvent, AuthEventType, AuthIdentity, AuthUser, CARDINALITY, CapabilityImpls, CapabilityKey, Cardinality, CardinalityFor, Ci, CiRun, CiRunFilter, CiRunStatus, ConnectionStringOptions, CreateAuthUserInput, Deployment, DeploymentProject, DeploymentProjectSettings, DeploymentRecord, DeploymentTarget, DeploymentTrigger, Dns, DnsRecord, DnsRecordType, EnsureResult, Environment, EnvironmentReviewer, Environments, Issue, IssueSearchFilter, Issues, LabelDef, LifecycleResult, LifecycleSlot, NormalizedAuthUser, Notifications, Observability, PagesConfig, ParseWebhookInput, ProviderApiError, ProviderIdentity, PullRequest, REQUIRED_CAPABILITIES, RepoRef, RepoSettings, ResolvedCapability, Ruleset, SecretScope, Secrets, Source, StatusCategory, Storage, StorageBranch, TeamEntry, TeamPermission, Tooling, ToolingDoctorReport, TrackerDoctorReport, TrackerUser, Vault, WebhookDashboardInfo, WebhookVerificationError, isMulti };
|
package/dist/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import path, { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
-
import { input, select } from "@inquirer/prompts";
|
|
5
|
+
import { checkbox, input, select } from "@inquirer/prompts";
|
|
6
6
|
import yargs from "yargs";
|
|
7
7
|
import { hideBin } from "yargs/helpers";
|
|
8
8
|
import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1 } from "@theholocron/http-client";
|
|
@@ -491,6 +491,216 @@ async function tryLoadHint(importer, packageName) {
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
//#endregion
|
|
494
|
+
//#region src/loader.ts
|
|
495
|
+
var LoaderError = class extends Error {
|
|
496
|
+
name = "LoaderError";
|
|
497
|
+
};
|
|
498
|
+
var PluginLoader = class {
|
|
499
|
+
config;
|
|
500
|
+
context;
|
|
501
|
+
importer;
|
|
502
|
+
registry = /* @__PURE__ */ new Map();
|
|
503
|
+
constructor(config, context, importer = defaultImporter) {
|
|
504
|
+
this.config = config;
|
|
505
|
+
this.context = context;
|
|
506
|
+
this.importer = importer;
|
|
507
|
+
}
|
|
508
|
+
/** Imports every configured plugin and builds the capability registry. */
|
|
509
|
+
async load() {
|
|
510
|
+
const entries = Object.entries(this.config.providers);
|
|
511
|
+
for (const [key, entry] of entries) {
|
|
512
|
+
if (!entry) continue;
|
|
513
|
+
if (entry.cardinality === "single") this.registry.set(key, await this.loadOne(key, entry.tuple));
|
|
514
|
+
else {
|
|
515
|
+
const impls = [];
|
|
516
|
+
for (const tuple of entry.tuples) impls.push(await this.loadOne(key, tuple));
|
|
517
|
+
this.registry.set(key, impls);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Type-safe lookup. Single-cardinality keys return one impl;
|
|
523
|
+
* many-cardinality keys return an array. `ResolvedCapability<K>`
|
|
524
|
+
* encodes the split via the `CARDINALITY` map.
|
|
525
|
+
*/
|
|
526
|
+
get(key) {
|
|
527
|
+
const impl = this.registry.get(key);
|
|
528
|
+
if (impl === void 0) throw new LoaderError(`capability \`${key}\` is not loaded — is it declared in holocron.config.json?`);
|
|
529
|
+
return impl;
|
|
530
|
+
}
|
|
531
|
+
/** Whether a capability has been loaded. */
|
|
532
|
+
has(key) {
|
|
533
|
+
return this.registry.has(key);
|
|
534
|
+
}
|
|
535
|
+
/** All capability keys currently loaded. Useful for the doctor command. */
|
|
536
|
+
loadedKeys() {
|
|
537
|
+
return Array.from(this.registry.keys());
|
|
538
|
+
}
|
|
539
|
+
/** Internal — invoke a plugin's capability factory and return the impl. */
|
|
540
|
+
async loadOne(key, tuple) {
|
|
541
|
+
const mod = await this.importer(tuple.packageName).catch((err) => {
|
|
542
|
+
throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
|
|
543
|
+
});
|
|
544
|
+
if (isPluginModule(mod)) {
|
|
545
|
+
const effectiveToken = this.context.cliTokens?.[tuple.provider] ?? this.context.cliToken;
|
|
546
|
+
const factory = mod.createPlugin({
|
|
547
|
+
...this.projectDefaults(),
|
|
548
|
+
...this.context,
|
|
549
|
+
...effectiveToken !== void 0 ? { cliToken: effectiveToken } : {},
|
|
550
|
+
cliTokens: void 0,
|
|
551
|
+
...tuple.options
|
|
552
|
+
}).capabilities[key];
|
|
553
|
+
if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
|
|
554
|
+
return factory();
|
|
555
|
+
}
|
|
556
|
+
if (isCapabilityConfigModule(mod)) {
|
|
557
|
+
const cap = mod.default;
|
|
558
|
+
return this.loadOne(key, {
|
|
559
|
+
provider: cap.provider,
|
|
560
|
+
packageName: resolvePluginPackage(cap.provider),
|
|
561
|
+
options: {
|
|
562
|
+
...cap.options,
|
|
563
|
+
...tuple.options
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\` or a capability config ({ provider, options? })`);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Project-level defaults that get merged into every plugin's options
|
|
571
|
+
* unless overridden by the CLI context or per-plugin tuple options.
|
|
572
|
+
* See `.notes/tech-setup-and-config.spec.md` §Design.
|
|
573
|
+
*/
|
|
574
|
+
projectDefaults() {
|
|
575
|
+
const defaults = {};
|
|
576
|
+
if (this.config.repo?.name) defaults.repo = this.config.repo.name;
|
|
577
|
+
return defaults;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
/** Default importer — native dynamic import. */
|
|
581
|
+
const defaultImporter = async (pkg) => {
|
|
582
|
+
return import(pkg);
|
|
583
|
+
};
|
|
584
|
+
function isPluginModule(mod) {
|
|
585
|
+
return typeof mod.createPlugin === "function";
|
|
586
|
+
}
|
|
587
|
+
function isCapabilityConfigModule(mod) {
|
|
588
|
+
return typeof mod.default?.provider === "string";
|
|
589
|
+
}
|
|
590
|
+
//#endregion
|
|
591
|
+
//#region src/commands/cleanup-preview.ts
|
|
592
|
+
function prStateLabel(pr) {
|
|
593
|
+
if (pr.merged) return style.success("merged");
|
|
594
|
+
if (pr.state === "closed") return style.dim("closed (not merged)");
|
|
595
|
+
return style.warn("open");
|
|
596
|
+
}
|
|
597
|
+
async function runCleanupPreview(input) {
|
|
598
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
599
|
+
// c8 ignore next -- real PluginLoader construction is integration-level; unit tests always supply loader
|
|
600
|
+
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
601
|
+
await loader.load();
|
|
602
|
+
if (!loader.has("source")) throw new Error("source capability is not configured — add a source provider to holocron.config.json");
|
|
603
|
+
const source = loader.get("source");
|
|
604
|
+
if (!source.getPullRequest) throw new Error(`${source.providerName} source provider does not support getPullRequest`);
|
|
605
|
+
let pr;
|
|
606
|
+
try {
|
|
607
|
+
pr = await source.getPullRequest(input.prNumber, input.repo);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
610
|
+
throw new Error(`Failed to fetch PR #${input.prNumber}: ${message}`, { cause: err });
|
|
611
|
+
}
|
|
612
|
+
const branch = `${(input.repo ?? input.loaded.resolved.repo?.name ?? "").split("/").pop()}-pr-${pr.number}`;
|
|
613
|
+
print("");
|
|
614
|
+
print(`${style.header(`PR #${pr.number}`)} — ${pr.title}`);
|
|
615
|
+
print(` Status : ${prStateLabel(pr)} | Branch : ${style.dim(pr.branch)}`);
|
|
616
|
+
print(` URL : ${style.dim(pr.url)}`);
|
|
617
|
+
print(` CF alias : ${style.dim(branch)}`);
|
|
618
|
+
print("");
|
|
619
|
+
if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a deployment provider to holocron.config.json");
|
|
620
|
+
const deploy = loader.get("deployment");
|
|
621
|
+
if (!deploy.listPreviewDeployments || !deploy.deletePreviewDeployments) throw new Error(`${deploy.providerName} deployment provider does not support preview cleanup`);
|
|
622
|
+
let deployments;
|
|
623
|
+
try {
|
|
624
|
+
deployments = await deploy.listPreviewDeployments(input.project, branch);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
627
|
+
throw new Error(`Failed to list deployments: ${message}`, { cause: err });
|
|
628
|
+
}
|
|
629
|
+
if (deployments.length === 0) {
|
|
630
|
+
print(style.dim(`No deployments found for branch ${branch}.`));
|
|
631
|
+
return {
|
|
632
|
+
pr,
|
|
633
|
+
branch,
|
|
634
|
+
found: 0,
|
|
635
|
+
deleted: 0,
|
|
636
|
+
status: "none"
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
print(`Found ${deployments.length} deployment${deployments.length === 1 ? "" : "s"} for ${style.dim(branch)}:`);
|
|
640
|
+
print("");
|
|
641
|
+
if (pr.state === "open") {
|
|
642
|
+
print(style.warn(`PR #${pr.number} is still open. Deleting its preview deployments will break the live preview link.`));
|
|
643
|
+
print(style.warn("Nothing is pre-selected — check the deployments you want to remove."));
|
|
644
|
+
print("");
|
|
645
|
+
}
|
|
646
|
+
const choices = deployments.map((d) => {
|
|
647
|
+
return {
|
|
648
|
+
name: `${d.id.includes(":") ? d.id.split(":").pop() : d.id} ${d.createdAt ? style.dim(d.createdAt.slice(0, 10)) : ""} ${style.dim(d.url)}`,
|
|
649
|
+
value: d.id,
|
|
650
|
+
checked: pr.state !== "open"
|
|
651
|
+
};
|
|
652
|
+
});
|
|
653
|
+
let selected;
|
|
654
|
+
try {
|
|
655
|
+
selected = await checkbox({
|
|
656
|
+
message: "Select deployments to delete (space to toggle, a to select all, enter to confirm):",
|
|
657
|
+
choices
|
|
658
|
+
});
|
|
659
|
+
} catch {
|
|
660
|
+
print(style.dim("Aborted."));
|
|
661
|
+
return {
|
|
662
|
+
pr,
|
|
663
|
+
branch,
|
|
664
|
+
found: deployments.length,
|
|
665
|
+
deleted: 0,
|
|
666
|
+
status: "aborted"
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (selected.length === 0) {
|
|
670
|
+
print(style.dim("Nothing selected — no deployments deleted."));
|
|
671
|
+
return {
|
|
672
|
+
pr,
|
|
673
|
+
branch,
|
|
674
|
+
found: deployments.length,
|
|
675
|
+
deleted: 0,
|
|
676
|
+
status: "aborted"
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
print("");
|
|
680
|
+
try {
|
|
681
|
+
const count = await deploy.deletePreviewDeployments(input.project, selected);
|
|
682
|
+
print(style.success(`Deleted ${count} deployment${count === 1 ? "" : "s"}.`));
|
|
683
|
+
return {
|
|
684
|
+
pr,
|
|
685
|
+
branch,
|
|
686
|
+
found: deployments.length,
|
|
687
|
+
deleted: count,
|
|
688
|
+
status: "ok"
|
|
689
|
+
};
|
|
690
|
+
} catch (err) {
|
|
691
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
692
|
+
print(style.fail(message));
|
|
693
|
+
return {
|
|
694
|
+
pr,
|
|
695
|
+
branch,
|
|
696
|
+
found: deployments.length,
|
|
697
|
+
deleted: 0,
|
|
698
|
+
status: "fail",
|
|
699
|
+
message
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
494
704
|
//#region src/commands/clone.ts
|
|
495
705
|
function encodeTokenForGitHttpAuth(token) {
|
|
496
706
|
const trimmed = token.trim();
|
|
@@ -593,103 +803,6 @@ async function runClone(input) {
|
|
|
593
803
|
};
|
|
594
804
|
}
|
|
595
805
|
//#endregion
|
|
596
|
-
//#region src/loader.ts
|
|
597
|
-
var LoaderError = class extends Error {
|
|
598
|
-
name = "LoaderError";
|
|
599
|
-
};
|
|
600
|
-
var PluginLoader = class {
|
|
601
|
-
config;
|
|
602
|
-
context;
|
|
603
|
-
importer;
|
|
604
|
-
registry = /* @__PURE__ */ new Map();
|
|
605
|
-
constructor(config, context, importer = defaultImporter) {
|
|
606
|
-
this.config = config;
|
|
607
|
-
this.context = context;
|
|
608
|
-
this.importer = importer;
|
|
609
|
-
}
|
|
610
|
-
/** Imports every configured plugin and builds the capability registry. */
|
|
611
|
-
async load() {
|
|
612
|
-
const entries = Object.entries(this.config.providers);
|
|
613
|
-
for (const [key, entry] of entries) {
|
|
614
|
-
if (!entry) continue;
|
|
615
|
-
if (entry.cardinality === "single") this.registry.set(key, await this.loadOne(key, entry.tuple));
|
|
616
|
-
else {
|
|
617
|
-
const impls = [];
|
|
618
|
-
for (const tuple of entry.tuples) impls.push(await this.loadOne(key, tuple));
|
|
619
|
-
this.registry.set(key, impls);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* Type-safe lookup. Single-cardinality keys return one impl;
|
|
625
|
-
* many-cardinality keys return an array. `ResolvedCapability<K>`
|
|
626
|
-
* encodes the split via the `CARDINALITY` map.
|
|
627
|
-
*/
|
|
628
|
-
get(key) {
|
|
629
|
-
const impl = this.registry.get(key);
|
|
630
|
-
if (impl === void 0) throw new LoaderError(`capability \`${key}\` is not loaded — is it declared in holocron.config.json?`);
|
|
631
|
-
return impl;
|
|
632
|
-
}
|
|
633
|
-
/** Whether a capability has been loaded. */
|
|
634
|
-
has(key) {
|
|
635
|
-
return this.registry.has(key);
|
|
636
|
-
}
|
|
637
|
-
/** All capability keys currently loaded. Useful for the doctor command. */
|
|
638
|
-
loadedKeys() {
|
|
639
|
-
return Array.from(this.registry.keys());
|
|
640
|
-
}
|
|
641
|
-
/** Internal — invoke a plugin's capability factory and return the impl. */
|
|
642
|
-
async loadOne(key, tuple) {
|
|
643
|
-
const mod = await this.importer(tuple.packageName).catch((err) => {
|
|
644
|
-
throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
|
|
645
|
-
});
|
|
646
|
-
if (isPluginModule(mod)) {
|
|
647
|
-
const effectiveToken = this.context.cliTokens?.[tuple.provider] ?? this.context.cliToken;
|
|
648
|
-
const factory = mod.createPlugin({
|
|
649
|
-
...this.projectDefaults(),
|
|
650
|
-
...this.context,
|
|
651
|
-
...effectiveToken !== void 0 ? { cliToken: effectiveToken } : {},
|
|
652
|
-
cliTokens: void 0,
|
|
653
|
-
...tuple.options
|
|
654
|
-
}).capabilities[key];
|
|
655
|
-
if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
|
|
656
|
-
return factory();
|
|
657
|
-
}
|
|
658
|
-
if (isCapabilityConfigModule(mod)) {
|
|
659
|
-
const cap = mod.default;
|
|
660
|
-
return this.loadOne(key, {
|
|
661
|
-
provider: cap.provider,
|
|
662
|
-
packageName: resolvePluginPackage(cap.provider),
|
|
663
|
-
options: {
|
|
664
|
-
...cap.options,
|
|
665
|
-
...tuple.options
|
|
666
|
-
}
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\` or a capability config ({ provider, options? })`);
|
|
670
|
-
}
|
|
671
|
-
/**
|
|
672
|
-
* Project-level defaults that get merged into every plugin's options
|
|
673
|
-
* unless overridden by the CLI context or per-plugin tuple options.
|
|
674
|
-
* See `.notes/tech-setup-and-config.spec.md` §Design.
|
|
675
|
-
*/
|
|
676
|
-
projectDefaults() {
|
|
677
|
-
const defaults = {};
|
|
678
|
-
if (this.config.repo?.name) defaults.repo = this.config.repo.name;
|
|
679
|
-
return defaults;
|
|
680
|
-
}
|
|
681
|
-
};
|
|
682
|
-
/** Default importer — native dynamic import. */
|
|
683
|
-
const defaultImporter = async (pkg) => {
|
|
684
|
-
return import(pkg);
|
|
685
|
-
};
|
|
686
|
-
function isPluginModule(mod) {
|
|
687
|
-
return typeof mod.createPlugin === "function";
|
|
688
|
-
}
|
|
689
|
-
function isCapabilityConfigModule(mod) {
|
|
690
|
-
return typeof mod.default?.provider === "string";
|
|
691
|
-
}
|
|
692
|
-
//#endregion
|
|
693
806
|
//#region src/commands/deploy.ts
|
|
694
807
|
async function runDeploy(input) {
|
|
695
808
|
const print = input.print ?? ((line) => console.log(line));
|
|
@@ -4649,7 +4762,7 @@ var dependencies_default = "name: Dependencies\n\non: # yamllint disable-line ru
|
|
|
4649
4762
|
var deploy_default = "name: Deploy\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n type:\n description: \"Type of deployment: docs or storybook\"\n required: true\n type: string\n name:\n description: Repo name prefix used to filter the docs site package (<name>-site); omit to run pnpm -C docs build\n required: false\n type: string\n default: \"\"\n storybook-projects:\n description: >\n JSON array of { \"name\"?, \"workingDir\", \"outputDir\"? } objects for storybook deploys.\n Each is built via `pnpm -C <workingDir> build:storybook`. If \"name\" is provided the\n output is placed under `sandbox/<name>/`; omit \"name\" for single-repo deploys and the\n output lands directly in `sandbox/`.\n type: string\n required: false\n default: \"[]\"\n build-script:\n description: pnpm script that builds the Storybook static output (single storybook, type:storybook only)\n type: string\n required: false\n default: build:storybook\n output-dir:\n description: Directory where Storybook writes its static output (single storybook, type:storybook only)\n type: string\n required: false\n default: storybook-static\n\njobs:\n deploy:\n name: Deploy\n runs-on: ubuntu-latest\n permissions:\n contents: read\n pages: write\n id-token: write\n environment:\n name: github-pages\n url: ${{ steps.deployment.outputs.page_url }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name != '' }}\n env:\n SITE_NAME: ${{ inputs.name }}\n run: pnpm --filter @theholocron/\"$SITE_NAME\"-site build\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name == '' }}\n run: pnpm -C docs build\n\n - name: Build Storybook projects\n if: ${{ inputs.storybook-projects != '[]' }}\n env:\n PROJECTS: ${{ inputs.storybook-projects }}\n run: |\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n pnpm -C \"$workingDir\" build:storybook\n done\n\n - name: Build Storybook\n if: ${{ inputs.type == 'storybook' && inputs.storybook-projects == '[]' }}\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n run: pnpm run \"$BUILD_SCRIPT\"\n\n - name: Assemble site\n env:\n DEPLOY_TYPE: ${{ inputs.type }}\n PROJECTS: ${{ inputs.storybook-projects }}\n STORYBOOK_OUTPUT_DIR: ${{ inputs.output-dir }}\n run: |\n mkdir -p _site\n if [ \"$DEPLOY_TYPE\" = \"docs\" ]; then\n cp -r docs/dist/. _site/\n fi\n if [ \"$PROJECTS\" != \"[]\" ]; then\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n name=$(echo \"$project\" | jq -r '.name // \"\"')\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n outputDir=$(echo \"$project\" | jq -r '.outputDir // \"storybook-static\"')\n if [ -n \"$name\" ]; then\n target=\"_site/sandbox/${name}\"\n else\n target=\"_site/sandbox\"\n fi\n mkdir -p \"$target\"\n cp -r \"${workingDir}/${outputDir}/.\" \"$target/\"\n done\n elif [ \"$DEPLOY_TYPE\" = \"storybook\" ]; then\n mkdir -p _site/sandbox\n cp -r \"${STORYBOOK_OUTPUT_DIR}/.\" _site/sandbox/\n fi\n\n - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1\n name: Upload pages artifact\n with:\n path: _site\n\n - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5\n id: deployment\n name: Deploy to GitHub Pages\n";
|
|
4650
4763
|
//#endregion
|
|
4651
4764
|
//#region src/templates/workflows/deploy-preview.yml
|
|
4652
|
-
var deploy_preview_default = "name: Deploy Preview\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n type:\n description: \"Type of deployment: docs or storybook\"\n required: true\n type: string\n name:\n description: Repo name prefix used to filter the docs site package (<name>-site); omit to run pnpm -C docs build\n required: false\n type: string\n default: \"\"\n storybook-projects:\n description: >\n JSON array of { \"name\"?, \"workingDir\", \"outputDir\"? } objects for storybook deploys.\n Each is built via `pnpm -C <workingDir> build:storybook`. If \"name\" is provided the\n output is placed under `sandbox/<name>/`; omit \"name\" for single-repo deploys and the\n output lands directly in `sandbox/`.\n type: string\n required: false\n default: \"[]\"\n build-script:\n description: pnpm script that builds the Storybook static output (single storybook, type:storybook only)\n type: string\n required: false\n default: build:storybook\n output-dir:\n description: Directory where Storybook writes its static output (single storybook, type:storybook only)\n type: string\n required: false\n default: storybook-static\n cloudflare-project:\n description: >\n Cloudflare Pages project name. Falls back to the CLOUDFLARE_PAGES_PROJECT\n org variable when omitted — set that variable once and all repos get previews\n without per-repo config.\n required: false\n type: string\n default: \"\"\n\njobs:\n deploy-preview:\n name: Deploy Preview\n runs-on: ubuntu-latest\n permissions:\n contents: read\n deployments: write\n pull-requests: write\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name != '' }}\n env:\n SITE_NAME: ${{ inputs.name }}\n run: pnpm --filter @theholocron/\"$SITE_NAME\"-site build\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name == '' }}\n run: pnpm -C docs build\n\n - name: Build Storybook projects\n if: ${{ inputs.storybook-projects != '[]' }}\n env:\n PROJECTS: ${{ inputs.storybook-projects }}\n run: |\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n pnpm -C \"$workingDir\" build:storybook\n done\n\n - name: Build Storybook\n if: ${{ inputs.type == 'storybook' && inputs.storybook-projects == '[]' }}\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n run: pnpm run \"$BUILD_SCRIPT\"\n\n - name: Assemble site\n env:\n DEPLOY_TYPE: ${{ inputs.type }}\n SITE_NAME: ${{ inputs.name }}\n PROJECTS: ${{ inputs.storybook-projects }}\n STORYBOOK_OUTPUT_DIR: ${{ inputs.output-dir }}\n run: |\n mkdir -p _site\n if [ \"$DEPLOY_TYPE\" = \"docs\" ]; then\n if [ -n \"$SITE_NAME\" ]; then\n # Site built with base: /<name>/ — nest under _site/<name>/ so asset\n # paths match (/name/_astro/... → served at /name/_astro/...).\n mkdir -p \"_site/${SITE_NAME}\"\n cp -r docs/dist/. \"_site/${SITE_NAME}/\"\n # Redirect root to the base path so the preview URL lands correctly.\n printf \"/ /%s/ 301\\n\" \"$SITE_NAME\" > _site/_redirects\n else\n cp -r docs/dist/. _site/\n fi\n fi\n if [ \"$PROJECTS\" != \"[]\" ]; then\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n name=$(echo \"$project\" | jq -r '.name // \"\"')\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n outputDir=$(echo \"$project\" | jq -r '.outputDir // \"storybook-static\"')\n if [ -n \"$name\" ]; then\n target=\"_site/sandbox/${name}\"\n else\n target=\"_site/sandbox\"\n fi\n mkdir -p \"$target\"\n cp -r \"${workingDir}/${outputDir}/.\" \"$target/\"\n done\n elif [ \"$DEPLOY_TYPE\" = \"storybook\" ]; then\n mkdir -p _site/sandbox\n cp -r \"${STORYBOOK_OUTPUT_DIR}/.\" _site/sandbox/\n fi\n\n - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0\n id: deploy\n name: Deploy to Cloudflare Pages\n # Skip when neither the per-repo input nor the org variable is set.\n if: ${{ inputs.cloudflare-project != '' || vars.CLOUDFLARE_PAGES_PROJECT != '' }}\n with:\n apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}\n packageManager: npm\n command: >-\n pages deploy _site\n --project-name ${{ inputs.cloudflare-project || vars.CLOUDFLARE_PAGES_PROJECT }}\n --branch ${{ github.event.repository.name }}-pr-${{ github.event.pull_request.number }}\n --commit-dirty=true\n\n - name: Report preview URL\n if: ${{ steps.deploy.outputs.pages-deployment-alias-url != '' }}\n env:\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n ALIAS: ${{ steps.deploy.outputs.pages-deployment-alias-url }}\n SITE_NAME: ${{ inputs.name }}\n PROJECT: ${{ inputs.cloudflare-project || vars.CLOUDFLARE_PAGES_PROJECT }}\n run: |\n PREVIEW_URL=\"${ALIAS%/}${SITE_NAME:+/${SITE_NAME}}/\"\n # GITHUB_HEAD_REF is the PR branch name — links the deployment to this PR's sidebar widget.\n PAYLOAD=$(printf '{\"ref\":\"%s\",\"environment\":\"%s\",\"description\":\"Cloudflare Pages\",\"production_environment\":false,\"auto_merge\":false,\"required_contexts\":[]}' \\\n \"$GITHUB_HEAD_REF\" \"${PROJECT} (Preview)\")\n DEPLOY_ID=$(echo \"$PAYLOAD\" | gh api \"repos/${GITHUB_REPOSITORY}/deployments\" \\\n --method POST --input - | jq -r '.id')\n gh api \"repos/${GITHUB_REPOSITORY}/deployments/${DEPLOY_ID}/statuses\" \\\n --method POST --field state=success --field environment_url=\"${PREVIEW_URL}\"\n";
|
|
4765
|
+
var deploy_preview_default = "name: Deploy Preview\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n type:\n description: \"Type of deployment: docs or storybook\"\n required: true\n type: string\n name:\n description: Repo name prefix used to filter the docs site package (<name>-site); omit to run pnpm -C docs build\n required: false\n type: string\n default: \"\"\n storybook-projects:\n description: >\n JSON array of { \"name\"?, \"workingDir\", \"outputDir\"? } objects for storybook deploys.\n Each is built via `pnpm -C <workingDir> build:storybook`. If \"name\" is provided the\n output is placed under `sandbox/<name>/`; omit \"name\" for single-repo deploys and the\n output lands directly in `sandbox/`.\n type: string\n required: false\n default: \"[]\"\n build-script:\n description: pnpm script that builds the Storybook static output (single storybook, type:storybook only)\n type: string\n required: false\n default: build:storybook\n output-dir:\n description: Directory where Storybook writes its static output (single storybook, type:storybook only)\n type: string\n required: false\n default: storybook-static\n cloudflare-project:\n description: >\n Cloudflare Pages project name. Falls back to the CLOUDFLARE_PAGES_PROJECT\n org variable when omitted — set that variable once and all repos get previews\n without per-repo config.\n required: false\n type: string\n default: \"\"\n\njobs:\n deploy-preview:\n name: Deploy Preview\n runs-on: ubuntu-latest\n permissions:\n contents: read\n deployments: write\n pull-requests: write\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name != '' }}\n env:\n SITE_NAME: ${{ inputs.name }}\n run: pnpm --filter @theholocron/\"$SITE_NAME\"-site build\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name == '' }}\n run: pnpm -C docs build\n\n - name: Build Storybook projects\n if: ${{ inputs.storybook-projects != '[]' }}\n env:\n PROJECTS: ${{ inputs.storybook-projects }}\n run: |\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n pnpm -C \"$workingDir\" build:storybook\n done\n\n - name: Build Storybook\n if: ${{ inputs.type == 'storybook' && inputs.storybook-projects == '[]' }}\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n run: pnpm run \"$BUILD_SCRIPT\"\n\n - name: Assemble site\n env:\n DEPLOY_TYPE: ${{ inputs.type }}\n SITE_NAME: ${{ inputs.name }}\n PROJECTS: ${{ inputs.storybook-projects }}\n STORYBOOK_OUTPUT_DIR: ${{ inputs.output-dir }}\n run: |\n mkdir -p _site\n if [ \"$DEPLOY_TYPE\" = \"docs\" ]; then\n if [ -n \"$SITE_NAME\" ]; then\n # Site built with base: /<name>/ — nest under _site/<name>/ so asset\n # paths match (/name/_astro/... → served at /name/_astro/...).\n mkdir -p \"_site/${SITE_NAME}\"\n cp -r docs/dist/. \"_site/${SITE_NAME}/\"\n # Redirect root to the base path so the preview URL lands correctly.\n printf \"/ /%s/ 301\\n\" \"$SITE_NAME\" > _site/_redirects\n else\n cp -r docs/dist/. _site/\n fi\n fi\n if [ \"$PROJECTS\" != \"[]\" ]; then\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n name=$(echo \"$project\" | jq -r '.name // \"\"')\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n outputDir=$(echo \"$project\" | jq -r '.outputDir // \"storybook-static\"')\n if [ -n \"$name\" ]; then\n target=\"_site/sandbox/${name}\"\n else\n target=\"_site/sandbox\"\n fi\n mkdir -p \"$target\"\n cp -r \"${workingDir}/${outputDir}/.\" \"$target/\"\n done\n elif [ \"$DEPLOY_TYPE\" = \"storybook\" ]; then\n mkdir -p _site/sandbox\n cp -r \"${STORYBOOK_OUTPUT_DIR}/.\" _site/sandbox/\n fi\n\n - name: Install Wrangler\n # The action's install step runs npm/pnpm in the repo root; pnpm fails\n # because it rejects adding to a workspace root without -w, and npm fails\n # because package.json contains pnpm catalog: references it cannot parse.\n # Pre-installing from /tmp (no package.json) lets the action find wrangler\n # already on PATH and skip its own install entirely.\n working-directory: /tmp\n run: npm install -g wrangler@4\n\n - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0\n id: deploy\n name: Deploy to Cloudflare Pages\n # Skip when neither the per-repo input nor the org variable is set.\n if: ${{ inputs.cloudflare-project != '' || vars.CLOUDFLARE_PAGES_PROJECT != '' }}\n with:\n apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}\n packageManager: npm\n command: >-\n pages deploy _site\n --project-name ${{ inputs.cloudflare-project || vars.CLOUDFLARE_PAGES_PROJECT }}\n --branch ${{ github.event.repository.name }}-pr-${{ github.event.pull_request.number }}\n --commit-dirty=true\n\n - name: Report preview URL\n if: ${{ steps.deploy.outputs.pages-deployment-alias-url != '' }}\n env:\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n ALIAS: ${{ steps.deploy.outputs.pages-deployment-alias-url }}\n SITE_NAME: ${{ inputs.name }}\n PROJECT: ${{ inputs.cloudflare-project || vars.CLOUDFLARE_PAGES_PROJECT }}\n run: |\n PREVIEW_URL=\"${ALIAS%/}${SITE_NAME:+/${SITE_NAME}}/\"\n # GITHUB_HEAD_REF is the PR branch name — links the deployment to this PR's sidebar widget.\n PAYLOAD=$(printf '{\"ref\":\"%s\",\"environment\":\"%s\",\"description\":\"Cloudflare Pages\",\"production_environment\":false,\"auto_merge\":false,\"required_contexts\":[]}' \\\n \"$GITHUB_HEAD_REF\" \"${PROJECT} (Preview)\")\n DEPLOY_ID=$(echo \"$PAYLOAD\" | gh api \"repos/${GITHUB_REPOSITORY}/deployments\" \\\n --method POST --input - | jq -r '.id')\n gh api \"repos/${GITHUB_REPOSITORY}/deployments/${DEPLOY_ID}/statuses\" \\\n --method POST --field state=success --field environment_url=\"${PREVIEW_URL}\"\n";
|
|
4653
4766
|
//#endregion
|
|
4654
4767
|
//#region src/templates/workflows/greetings.yml
|
|
4655
4768
|
var greetings_default = "name: Greetings\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n\njobs:\n greeting:\n name: Greet first-time contributors\n permissions:\n issues: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n # Group by the issue/PR number so duplicate events don't race each other.\n steps:\n - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n name: Greet on first contribution\n with:\n script: |\n // Only greet on the initial open — ignore synchronize, reopened, etc.\n if (context.payload.action !== 'opened') return;\n\n const actor = context.actor;\n const { owner, repo } = context.repo;\n\n // Payload inspection is more reliable than context.eventName for detecting\n // whether this is an issue vs. PR event — works regardless of how GitHub\n // propagates event names through workflow_call chains.\n const isIssue = !!context.payload.issue && !context.payload.pull_request;\n // listForRepo returns both issues and PRs (GitHub treats PRs as issues),\n // sorted newest-first. Filter by type to track first-issue and first-PR\n // independently, and avoid search-index eventual-consistency lag.\n const { data: recent } = await github.rest.issues.listForRepo({\n owner, repo,\n creator: actor,\n state: 'all',\n per_page: 100\n });\n\n const sameType = recent.filter(item =>\n isIssue ? !item.pull_request : !!item.pull_request\n );\n\n if (sameType.length !== 1) return;\n const body = isIssue\n ? `Hey @${actor}!\\n\\nWe really appreciate you taking the time to report an issue. The collaborators on this project attempt to help as many people as possible, but we are a limited number of volunteers, so it is possible that this will not be addressed as swiftly.\\n\\nYour patience is much appreciated and we will get back to you as quickly as possible.`\n : `Hey @${actor}!\\n\\nWe really appreciate you taking the time to help out with this PR. The collaborators on this project attempt to help as many people as possible, but we are a limited number of volunteers, so it is possible that this will not be addressed as swiftly.\\n\\nYour patience is much appreciated and we will get back to you as quickly as possible.`;\n\n await github.rest.issues.createComment({\n owner,\n repo,\n issue_number: context.issue.number,\n body\n });\n";
|
|
@@ -5805,6 +5918,33 @@ try {
|
|
|
5805
5918
|
branch: argv.branch,
|
|
5806
5919
|
...argv.target ? { target: argv.target } : {}
|
|
5807
5920
|
})).status === "fail") process.exitCode = 1;
|
|
5921
|
+
}).command("cleanup-preview <pr>", "List and delete Cloudflare Pages preview deployments for a GitHub PR", (y) => y.positional("pr", {
|
|
5922
|
+
type: "number",
|
|
5923
|
+
demandOption: true,
|
|
5924
|
+
describe: "PR number to clean up"
|
|
5925
|
+
}).option("project", {
|
|
5926
|
+
type: "string",
|
|
5927
|
+
demandOption: true,
|
|
5928
|
+
describe: "Cloudflare Pages project name (e.g. theholocron-preview)"
|
|
5929
|
+
}).option("repo", {
|
|
5930
|
+
type: "string",
|
|
5931
|
+
describe: "GitHub repo as owner/name — defaults to the repo in holocron.config"
|
|
5932
|
+
}), async (argv) => {
|
|
5933
|
+
const tokens = tokenContext(argv.token);
|
|
5934
|
+
if (!tokens) return;
|
|
5935
|
+
const loaded = await loadConfig(argv.cwd);
|
|
5936
|
+
if ((await runCleanupPreview({
|
|
5937
|
+
loaded,
|
|
5938
|
+
context: {
|
|
5939
|
+
repoRoot: argv.cwd,
|
|
5940
|
+
dryRun: argv.dryRun,
|
|
5941
|
+
...tokens,
|
|
5942
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
5943
|
+
},
|
|
5944
|
+
prNumber: argv.pr,
|
|
5945
|
+
project: argv.project,
|
|
5946
|
+
...argv.repo ? { repo: argv.repo } : {}
|
|
5947
|
+
})).status === "fail") process.exitCode = 1;
|
|
5808
5948
|
}).command("npm", "npm-related monorepo utilities", (y) => y.command("bump-versions <new-version>", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (yy) => yy.positional("new-version", {
|
|
5809
5949
|
type: "string",
|
|
5810
5950
|
demandOption: true,
|