@davidvornholt/standards 0.6.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/cli.ts +84 -2
package/README.md CHANGED
@@ -18,6 +18,7 @@ standards github --apply
18
18
 
19
19
  ## Configuration
20
20
 
21
+ - **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
21
22
  - **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` and private-repo reads need an authenticated token, and repo merge settings are only visible (and thus verifiable) to admin tokens — non-admin runs report them as unverifiable.
22
23
 
23
24
  See the standards repository README for the ownership model and adoption workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -755,6 +755,8 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
755
755
  );
756
756
  }
757
757
 
758
+ problems.push(...(await inspectPolicy(consumer)));
759
+
758
760
  if (problems.length > 0) {
759
761
  console.error(
760
762
  `standards doctor: ${problems.length} integration problem(s):`,
@@ -915,6 +917,82 @@ const runCheckCommand = async (consumer: string): Promise<boolean> => {
915
917
  );
916
918
  };
917
919
 
920
+ // Consumer-owned sync policy, checked in next to the canonical (read-only)
921
+ // standards-sync workflow it configures — versioned and reviewable, unlike
922
+ // repository Actions variables. All fields are optional; a missing file means
923
+ // the defaults (track main, weekly auto-sync on).
924
+ // autoSync false skips the scheduled workflow run; manual dispatch and
925
+ // local CLI runs are deliberate acts and always proceed.
926
+ // ref tag, branch, or full commit sha to sync from instead of main.
927
+ const POLICY_FILE = 'sync-standards.local.json';
928
+ const LINE_BREAK = /[\r\n]/u;
929
+
930
+ type Policy = {
931
+ readonly autoSync?: boolean;
932
+ readonly ref?: string;
933
+ };
934
+
935
+ const parsePolicy = (parsed: unknown): Policy => {
936
+ if (!isRecord(parsed)) {
937
+ throw new Error(`${POLICY_FILE} must be a JSON object`);
938
+ }
939
+ const unsupportedFields = Object.keys(parsed).filter(
940
+ (field) => field !== 'autoSync' && field !== 'ref',
941
+ );
942
+ if (unsupportedFields.length > 0) {
943
+ throw new Error(
944
+ `${POLICY_FILE} contains unsupported field(s): ${unsupportedFields.join(', ')}`,
945
+ );
946
+ }
947
+ if (parsed.autoSync !== undefined && typeof parsed.autoSync !== 'boolean') {
948
+ throw new Error(`${POLICY_FILE} "autoSync" must be a boolean`);
949
+ }
950
+ if (
951
+ parsed.ref !== undefined &&
952
+ (!isNonEmptyString(parsed.ref) || LINE_BREAK.test(parsed.ref))
953
+ ) {
954
+ throw new Error(
955
+ `${POLICY_FILE} "ref" must be a non-empty single-line string`,
956
+ );
957
+ }
958
+ return { autoSync: parsed.autoSync, ref: parsed.ref };
959
+ };
960
+
961
+ const readPolicy = async (consumer: string): Promise<Policy> => {
962
+ const raw = await readTextIfPresent(join(consumer, POLICY_FILE));
963
+ if (raw === null) {
964
+ return {};
965
+ }
966
+ let parsed: unknown;
967
+ try {
968
+ parsed = JSON.parse(raw);
969
+ } catch (error) {
970
+ throw new Error(`${POLICY_FILE} must contain valid JSON`, { cause: error });
971
+ }
972
+ return parsePolicy(parsed);
973
+ };
974
+
975
+ const inspectPolicy = async (
976
+ consumer: string,
977
+ ): Promise<ReadonlyArray<string>> => {
978
+ try {
979
+ await readPolicy(consumer);
980
+ return [];
981
+ } catch (error) {
982
+ return [error instanceof Error ? error.message : String(error)];
983
+ }
984
+ };
985
+
986
+ // Policy validation is unconditional once the file exists. Selection happens
987
+ // afterward: explicit refs win for remote sources, while local paths are used
988
+ // as-is and ignore only the already-validated policy ref.
989
+ const selectedRef = (
990
+ src: string,
991
+ explicitRef: string | undefined,
992
+ policy: Policy,
993
+ ): string | undefined =>
994
+ existsSync(src) ? explicitRef : (explicitRef ?? policy.ref);
995
+
918
996
  const runInitCommand = async (
919
997
  consumer: string,
920
998
  from: string | undefined,
@@ -930,7 +1008,9 @@ const runInitCommand = async (
930
1008
  process.exitCode = 1;
931
1009
  return;
932
1010
  }
933
- const source = resolveSource(from ?? DEFAULT_UPSTREAM, ref);
1011
+ const src = from ?? DEFAULT_UPSTREAM;
1012
+ const policy = await readPolicy(consumer);
1013
+ const source = resolveSource(src, selectedRef(src, ref, policy));
934
1014
  try {
935
1015
  const manifest = await loadManifest(
936
1016
  join(source.dir, 'sync-standards.json'),
@@ -950,7 +1030,9 @@ const runSyncCommand = async (
950
1030
  const consumerManifest = await loadManifest(
951
1031
  join(consumer, 'sync-standards.json'),
952
1032
  );
953
- const source = resolveSource(from ?? consumerManifest.upstream, ref);
1033
+ const src = from ?? consumerManifest.upstream;
1034
+ const policy = await readPolicy(consumer);
1035
+ const source = resolveSource(src, selectedRef(src, ref, policy));
954
1036
  try {
955
1037
  const manifest = await loadManifest(
956
1038
  join(source.dir, 'sync-standards.json'),