@git.zone/cli 2.18.1 → 2.19.1

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 (41) hide show
  1. package/.smartconfig.json +2 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/gitzone.cli.d.ts +1 -1
  4. package/dist_ts/gitzone.cli.js +100 -126
  5. package/dist_ts/helpers.climode.d.ts +2 -0
  6. package/dist_ts/helpers.climode.js +31 -2
  7. package/dist_ts/helpers.smartconfigmigrations.js +53 -4
  8. package/dist_ts/helpers.workflow.d.ts +12 -2
  9. package/dist_ts/helpers.workflow.js +9 -2
  10. package/dist_ts/mod_config/index.js +254 -29
  11. package/dist_ts/mod_format/classes.baseformatter.d.ts +3 -1
  12. package/dist_ts/mod_format/classes.baseformatter.js +7 -1
  13. package/dist_ts/mod_format/classes.formatplanner.d.ts +2 -0
  14. package/dist_ts/mod_format/classes.formatplanner.js +48 -4
  15. package/dist_ts/mod_format/formatters/license.formatter.d.ts +4 -1
  16. package/dist_ts/mod_format/formatters/license.formatter.js +32 -10
  17. package/dist_ts/mod_format/formatters/prettier.formatter.d.ts +0 -3
  18. package/dist_ts/mod_format/formatters/prettier.formatter.js +53 -62
  19. package/dist_ts/mod_format/index.d.ts +1 -1
  20. package/dist_ts/mod_format/index.js +237 -8
  21. package/dist_ts/mod_format/interfaces.format.d.ts +7 -11
  22. package/dist_ts/mod_format/interfaces.format.js +1 -1
  23. package/dist_ts/mod_release/index.js +50 -23
  24. package/dist_ts/mod_standard/index.js +2 -1
  25. package/package.json +1 -1
  26. package/readme.hints.md +10 -0
  27. package/readme.md +31 -4
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/gitzone.cli.ts +104 -144
  30. package/ts/helpers.climode.ts +36 -1
  31. package/ts/helpers.smartconfigmigrations.ts +57 -3
  32. package/ts/helpers.workflow.ts +20 -3
  33. package/ts/mod_config/index.ts +278 -29
  34. package/ts/mod_format/classes.baseformatter.ts +13 -1
  35. package/ts/mod_format/classes.formatplanner.ts +66 -4
  36. package/ts/mod_format/formatters/license.formatter.ts +43 -15
  37. package/ts/mod_format/formatters/prettier.formatter.ts +54 -66
  38. package/ts/mod_format/index.ts +289 -8
  39. package/ts/mod_format/interfaces.format.ts +8 -11
  40. package/ts/mod_release/index.ts +46 -23
  41. package/ts/mod_standard/index.ts +1 -0
@@ -88,6 +88,41 @@ const parseRawArgv = (argv: string[]): TArgSource => {
88
88
  return parsedArgv;
89
89
  };
90
90
 
91
+ export const parseCliArgv = parseRawArgv;
92
+
93
+ export const getProcessUserArgv = (): string[] => {
94
+ const rawArgv = process.argv;
95
+ const argv0Base = (rawArgv[0] || "").split(/[\\/]/).pop()?.toLowerCase();
96
+ const runtimeNames = new Set([
97
+ "node",
98
+ "node.exe",
99
+ "nodejs",
100
+ "nodejs.exe",
101
+ "bun",
102
+ "bun.exe",
103
+ "deno",
104
+ "deno.exe",
105
+ "tsx",
106
+ "tsx.exe",
107
+ "ts-node",
108
+ "ts-node.exe",
109
+ ]);
110
+
111
+ if (!runtimeNames.has(argv0Base || "")) {
112
+ return rawArgv.slice();
113
+ }
114
+
115
+ const firstUserArg = rawArgv[1] || "";
116
+ const firstUserArgLooksLikeScript =
117
+ firstUserArg.includes("/") ||
118
+ firstUserArg.endsWith(".js") ||
119
+ firstUserArg.endsWith(".ts") ||
120
+ firstUserArg.endsWith(".mjs") ||
121
+ firstUserArg.endsWith(".cjs");
122
+
123
+ return rawArgv.slice(firstUserArgLooksLikeScript ? 2 : 1);
124
+ };
125
+
91
126
  const normalizeOutputMode = (value: unknown): TCliOutputMode | undefined => {
92
127
  if (value === "human" || value === "plain" || value === "json") {
93
128
  return value;
@@ -171,7 +206,7 @@ export const getCliMode = async (
171
206
 
172
207
  export const getRawCliMode = async (): Promise<ICliMode> => {
173
208
  const cliConfig = await getCliModeConfig();
174
- const rawArgv = parseRawArgv(process.argv.slice(2));
209
+ const rawArgv = parseRawArgv(getProcessUserArgv());
175
210
  return resolveCliMode(rawArgv, cliConfig);
176
211
  };
177
212
 
@@ -19,6 +19,38 @@ const ensureObject = (parent: Record<string, any>, key: string): Record<string,
19
19
  return parent[key];
20
20
  };
21
21
 
22
+ const normalizeRegistryList = (registries: unknown[]): string[] => {
23
+ const result: string[] = [];
24
+ for (const registry of registries) {
25
+ if (typeof registry !== "string" || !registry.trim()) {
26
+ continue;
27
+ }
28
+ const normalizedRegistry = normalizeRegistryUrl(registry);
29
+ if (!result.includes(normalizedRegistry)) {
30
+ result.push(normalizedRegistry);
31
+ }
32
+ }
33
+ return result;
34
+ };
35
+
36
+ const migrateLegacyReleaseArray = (smartconfigJson: Record<string, any>): boolean => {
37
+ const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
38
+ if (!Array.isArray(cliConfig.release)) {
39
+ return false;
40
+ }
41
+
42
+ const registries = normalizeRegistryList(cliConfig.release);
43
+ cliConfig.release = {
44
+ targets: {
45
+ npm: {
46
+ enabled: registries.length > 0,
47
+ registries,
48
+ },
49
+ },
50
+ };
51
+ return true;
52
+ };
53
+
22
54
  const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => {
23
55
  let migrated = false;
24
56
  const migrations = [
@@ -50,9 +82,9 @@ const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean =>
50
82
 
51
83
  const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
52
84
  const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
85
+ let migrated = migrateLegacyReleaseArray(smartconfigJson);
53
86
  const releaseConfig = ensureObject(cliConfig, "release");
54
87
 
55
- let migrated = false;
56
88
  const targets = ensureObject(releaseConfig, "targets");
57
89
  const shipzoneConfig = smartconfigJson["@ship.zone/szci"];
58
90
 
@@ -68,8 +100,10 @@ const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
68
100
  migrated = true;
69
101
  }
70
102
 
71
- if (isPlainObject(releaseConfig.docker) && !isPlainObject(targets.docker)) {
72
- targets.docker = releaseConfig.docker;
103
+ if (isPlainObject(releaseConfig.docker)) {
104
+ targets.docker = isPlainObject(targets.docker)
105
+ ? { ...releaseConfig.docker, ...targets.docker }
106
+ : releaseConfig.docker;
73
107
  delete releaseConfig.docker;
74
108
  migrated = true;
75
109
  }
@@ -141,11 +175,27 @@ const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
141
175
  if (dockerTarget.enabled === undefined) {
142
176
  dockerTarget.enabled = true;
143
177
  }
178
+ dockerTarget.engine = "tsdocker";
144
179
  }
145
180
  delete releaseConfig.steps;
146
181
  migrated = true;
147
182
  }
148
183
 
184
+ if (isPlainObject(targets.docker)) {
185
+ if (targets.docker.images) {
186
+ delete targets.docker.images;
187
+ migrated = true;
188
+ }
189
+ if (targets.docker.engine !== "tsdocker") {
190
+ targets.docker.engine = "tsdocker";
191
+ migrated = true;
192
+ }
193
+ if (!Array.isArray(targets.docker.patterns)) {
194
+ targets.docker.patterns = [];
195
+ migrated = true;
196
+ }
197
+ }
198
+
149
199
  if (releaseConfig.changelog) {
150
200
  delete releaseConfig.changelog;
151
201
  migrated = true;
@@ -174,6 +224,10 @@ export const migrateSmartconfigData = (
174
224
  const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1;
175
225
  let currentVersion = fromVersion;
176
226
 
227
+ if (targetVersion >= 2) {
228
+ migrated = migrateLegacyReleaseArray(smartconfigJson) || migrated;
229
+ }
230
+
177
231
  if (currentVersion < 2 && targetVersion >= 2) {
178
232
  migrated = migrateToV2(smartconfigJson) || migrated;
179
233
  currentVersion = 2;
@@ -52,7 +52,12 @@ export interface IReleaseNpmTargetConfig {
52
52
 
53
53
  export interface IReleaseDockerTargetConfig {
54
54
  enabled?: boolean;
55
- images?: string[];
55
+ engine?: "tsdocker";
56
+ patterns?: string[];
57
+ cached?: boolean;
58
+ parallel?: boolean | number;
59
+ context?: string;
60
+ noBuild?: boolean;
56
61
  }
57
62
 
58
63
  export interface IReleaseWorkflowConfig {
@@ -109,7 +114,12 @@ export interface IResolvedReleaseWorkflow {
109
114
  npmAccessLevel: "public" | "private";
110
115
  npmAlreadyPublished: "success" | "error";
111
116
  dockerEnabled: boolean;
112
- dockerImages: string[];
117
+ dockerEngine: "tsdocker";
118
+ dockerPatterns: string[];
119
+ dockerCached: boolean;
120
+ dockerParallel: boolean | number;
121
+ dockerContext?: string;
122
+ dockerNoBuild: boolean;
113
123
  }
114
124
 
115
125
  interface ICliWorkflowConfig {
@@ -382,6 +392,13 @@ export const resolveReleaseWorkflow = async (argvArg: any): Promise<IResolvedRel
382
392
  npmAccessLevel: npmConfig.accessLevel || "public",
383
393
  npmAlreadyPublished: npmConfig.alreadyPublished || "success",
384
394
  dockerEnabled,
385
- dockerImages: dockerConfig.images || [],
395
+ dockerEngine: "tsdocker",
396
+ dockerPatterns: Array.isArray(dockerConfig.patterns) ? dockerConfig.patterns : [],
397
+ dockerCached: dockerConfig.cached ?? false,
398
+ dockerParallel: dockerConfig.parallel ?? false,
399
+ dockerContext: typeof dockerConfig.context === "string" && dockerConfig.context.trim()
400
+ ? dockerConfig.context.trim()
401
+ : undefined,
402
+ dockerNoBuild: dockerConfig.noBuild ?? false,
386
403
  };
387
404
  };
@@ -168,7 +168,7 @@ async function handleInteractiveMenu(): Promise<void> {
168
168
  { name: "Configure release workflow", value: "release" },
169
169
  { name: "Configure services", value: "services" },
170
170
  { name: "Validate configuration (doctor)", value: "doctor" },
171
- { name: "Fix configuration with opencode", value: "fix" },
171
+ { name: "Fix configuration", value: "fix" },
172
172
  { name: "Add an npm target registry", value: "add" },
173
173
  { name: "Remove an npm target registry", value: "remove" },
174
174
  { name: "Clear npm target registries", value: "clear" },
@@ -793,7 +793,7 @@ async function handleRelease(mode: ICliMode): Promise<void> {
793
793
  choices: [
794
794
  { name: "git - push branch and tags", value: "git" },
795
795
  { name: "npm - publish package registries", value: "npm" },
796
- { name: "docker - build and push images", value: "docker" },
796
+ { name: "docker - build and push through tsdocker", value: "docker" },
797
797
  ],
798
798
  default: getDefaultEnabledTargets(currentTargets),
799
799
  });
@@ -860,21 +860,49 @@ async function handleRelease(mode: ICliMode): Promise<void> {
860
860
  }
861
861
 
862
862
  if (enabledTargets.includes("docker")) {
863
- const images = await askValue<string>(interactInstance, {
863
+ const patterns = await askValue<string>(interactInstance, {
864
864
  type: "input",
865
- name: "dockerImages",
866
- message: "Docker image templates (comma-separated, supports {{version}}):",
867
- default: Array.isArray(currentTargets.docker?.images)
868
- ? currentTargets.docker.images.join(", ")
865
+ name: "dockerPatterns",
866
+ message: "tsdocker Dockerfile patterns (comma-separated, empty means all):",
867
+ default: Array.isArray(currentTargets.docker?.patterns)
868
+ ? currentTargets.docker.patterns.join(", ")
869
869
  : "",
870
870
  });
871
+ const cached = await askValue<boolean>(interactInstance, {
872
+ type: "confirm",
873
+ name: "dockerCached",
874
+ message: "Use tsdocker cached builds?",
875
+ default: currentTargets.docker?.cached ?? false,
876
+ });
877
+ const parallel = await askValue<string>(interactInstance, {
878
+ type: "input",
879
+ name: "dockerParallel",
880
+ message: "tsdocker parallel mode (false, true, or concurrency number):",
881
+ default: formatDockerParallel(currentTargets.docker?.parallel ?? false),
882
+ });
883
+ const context = await askValue<string>(interactInstance, {
884
+ type: "input",
885
+ name: "dockerContext",
886
+ message: "Docker context for tsdocker (empty for default):",
887
+ default: currentTargets.docker?.context || "",
888
+ });
889
+ const noBuild = await askValue<boolean>(interactInstance, {
890
+ type: "confirm",
891
+ name: "dockerNoBuild",
892
+ message: "Skip tsdocker build and only push existing local registry images?",
893
+ default: currentTargets.docker?.noBuild ?? false,
894
+ });
871
895
  releaseTargets.docker = {
872
- ...(currentTargets.docker || {}),
873
896
  enabled: true,
874
- images: parseCsv(images),
897
+ engine: "tsdocker",
898
+ patterns: parseCsv(patterns),
899
+ cached,
900
+ parallel: parseDockerParallel(parallel),
901
+ context: context.trim() || undefined,
902
+ noBuild,
875
903
  };
876
904
  } else {
877
- releaseTargets.docker = { ...(currentTargets.docker || {}), enabled: false };
905
+ releaseTargets.docker = { enabled: false, engine: "tsdocker" };
878
906
  }
879
907
 
880
908
  setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION);
@@ -911,8 +939,8 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
911
939
  return;
912
940
  }
913
941
 
914
- const findings = await collectDoctorFindings();
915
- const counts = countDoctorFindings(findings);
942
+ let findings = await collectDoctorFindings();
943
+ let counts = countDoctorFindings(findings);
916
944
  const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim();
917
945
  const force = Boolean(argvArg.force);
918
946
 
@@ -926,10 +954,10 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
926
954
 
927
955
  if (!mode.yes) {
928
956
  if (!mode.interactive) {
929
- throw new Error("Config fix requires an interactive terminal or `-y` to run opencode non-interactively.");
957
+ throw new Error("Config fix requires an interactive terminal or `-y` to run non-interactively.");
930
958
  }
931
959
  const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
932
- `Run opencode to fix .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`,
960
+ `Run configuration fixes for .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`,
933
961
  true,
934
962
  );
935
963
  if (!confirmed) {
@@ -938,6 +966,16 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
938
966
  }
939
967
  }
940
968
 
969
+ const appliedKnownFixes = await applyKnownConfigFixes(mode);
970
+ if (appliedKnownFixes) {
971
+ findings = await collectDoctorFindings();
972
+ counts = countDoctorFindings(findings);
973
+ if (counts.error === 0 && counts.warn === 0 && !extraInstructions && !force) {
974
+ printDoctorResult(findings, mode);
975
+ return;
976
+ }
977
+ }
978
+
941
979
  const opencodeArgs = [
942
980
  "run",
943
981
  "--title",
@@ -976,6 +1014,33 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
976
1014
  printDoctorResult(finalFindings, mode);
977
1015
  }
978
1016
 
1017
+ async function applyKnownConfigFixes(mode: ICliMode): Promise<boolean> {
1018
+ const smartconfigPath = getSmartconfigPath();
1019
+ if (!(await plugins.smartfs.file(smartconfigPath).exists())) {
1020
+ return false;
1021
+ }
1022
+
1023
+ let smartconfigData: Record<string, any>;
1024
+ try {
1025
+ smartconfigData = await readSmartconfigFile();
1026
+ } catch {
1027
+ return false;
1028
+ }
1029
+
1030
+ const result = migrateSmartconfigData(smartconfigData);
1031
+ if (!result.migrated) {
1032
+ return false;
1033
+ }
1034
+
1035
+ await writeSmartconfigFile(smartconfigData);
1036
+ plugins.logger.log(
1037
+ "success",
1038
+ `Applied known .smartconfig.json migrations to schema v${result.toVersion}`,
1039
+ );
1040
+ await formatSmartconfigWithDiff(mode);
1041
+ return true;
1042
+ }
1043
+
979
1044
  async function collectDoctorFindings(): Promise<IDoctorFinding[]> {
980
1045
  const findings: IDoctorFinding[] = [];
981
1046
  const smartconfigPath = getSmartconfigPath();
@@ -1043,7 +1108,7 @@ async function collectDoctorFindings(): Promise<IDoctorFinding[]> {
1043
1108
  await validateDetectedProjectType(cliConfig, findings);
1044
1109
 
1045
1110
  validateCommitConfig(cliConfig.commit || {}, findings);
1046
- await validateReleaseConfig(cliConfig.release || {}, findings);
1111
+ await validateReleaseConfig(cliConfig.release, smartconfigData, findings);
1047
1112
 
1048
1113
  return findings;
1049
1114
  }
@@ -1291,9 +1356,14 @@ function formatTarget(enabled: unknown, targetConfig: any): string {
1291
1356
  details.push(`registries=${targetConfig.registries.length}`);
1292
1357
  }
1293
1358
  if (targetConfig.accessLevel) details.push(`access=${targetConfig.accessLevel}`);
1294
- if (Array.isArray(targetConfig.images)) {
1295
- details.push(`images=${targetConfig.images.length}`);
1296
- }
1359
+ if (targetConfig.engine) details.push(`engine=${targetConfig.engine}`);
1360
+ if (Array.isArray(targetConfig.patterns)) {
1361
+ details.push(`patterns=${targetConfig.patterns.length}`);
1362
+ }
1363
+ if (targetConfig.cached) details.push("cached=true");
1364
+ if (targetConfig.parallel) details.push(`parallel=${targetConfig.parallel}`);
1365
+ if (targetConfig.context) details.push(`context=${targetConfig.context}`);
1366
+ if (targetConfig.noBuild) details.push("noBuild=true");
1297
1367
  return details.length > 0 ? `${state} (${details.join(", ")})` : state;
1298
1368
  }
1299
1369
 
@@ -1338,6 +1408,29 @@ function parseCsv(value: string): string[] {
1338
1408
  return result;
1339
1409
  }
1340
1410
 
1411
+ function formatDockerParallel(value: unknown): string {
1412
+ if (value === true) return "true";
1413
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
1414
+ return String(Math.floor(value));
1415
+ }
1416
+ return "false";
1417
+ }
1418
+
1419
+ function parseDockerParallel(value: string): boolean | number {
1420
+ const normalizedValue = value.trim().toLowerCase();
1421
+ if (!normalizedValue || ["false", "no", "off", "0"].includes(normalizedValue)) {
1422
+ return false;
1423
+ }
1424
+ if (["true", "yes", "on"].includes(normalizedValue)) {
1425
+ return true;
1426
+ }
1427
+ const numericValue = Number(normalizedValue);
1428
+ if (Number.isFinite(numericValue) && numericValue > 0) {
1429
+ return Math.floor(numericValue);
1430
+ }
1431
+ return false;
1432
+ }
1433
+
1341
1434
  function normalizeRegistryUrl(url: string): string {
1342
1435
  let normalizedUrl = url.trim();
1343
1436
  if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
@@ -1391,6 +1484,7 @@ function buildConfigFixPrompt(
1391
1484
  `- Use schemaVersion ${CURRENT_GITZONE_CLI_SCHEMA_VERSION} for ` +
1392
1485
  "`@git.zone/cli`.",
1393
1486
  "- Use target-based release config: `release.targets.git`, `release.targets.npm`, and `release.targets.docker`.",
1487
+ "- Docker release targets must use `release.targets.docker.engine = \"tsdocker\"`; Docker registries belong under `@git.zone/tsdocker`.",
1394
1488
  "- Keep npm registries only at `@git.zone/cli.release.targets.npm.registries`.",
1395
1489
  "- Do not add runtime legacy compatibility code. If legacy config exists, migrate it explicitly.",
1396
1490
  "- Do not commit, release, install dependencies, or modify unrelated files.",
@@ -1513,9 +1607,24 @@ function validateCommitConfig(
1513
1607
  }
1514
1608
 
1515
1609
  async function validateReleaseConfig(
1516
- releaseConfig: Record<string, any>,
1610
+ rawReleaseConfig: unknown,
1611
+ smartconfigData: Record<string, any>,
1517
1612
  findings: IDoctorFinding[],
1518
1613
  ): Promise<void> {
1614
+ const releaseConfig = rawReleaseConfig === undefined ? {} : rawReleaseConfig;
1615
+ if (!isPlainObject(releaseConfig)) {
1616
+ findings.push({
1617
+ level: "error",
1618
+ message: `Release config must be an object, found ${
1619
+ Array.isArray(releaseConfig) ? "array" : typeof releaseConfig
1620
+ }`,
1621
+ fix: Array.isArray(releaseConfig)
1622
+ ? "Run `gitzone config migrate` to move legacy registry arrays into release.targets.npm.registries."
1623
+ : "Set @git.zone/cli.release to an object or remove it.",
1624
+ });
1625
+ return;
1626
+ }
1627
+
1519
1628
  const confirmation = releaseConfig.confirmation;
1520
1629
  if (confirmation === undefined || validConfirmationModes.includes(confirmation)) {
1521
1630
  findings.push({ level: "ok", message: "Release confirmation mode is valid" });
@@ -1554,7 +1663,7 @@ async function validateReleaseConfig(
1554
1663
  const targets = releaseConfig.targets || {};
1555
1664
  await validateGitTarget(targets.git || {}, findings);
1556
1665
  await validateNpmTarget(targets.npm || {}, findings);
1557
- validateDockerTarget(targets.docker || {}, findings);
1666
+ await validateDockerTarget(targets.docker || {}, smartconfigData, findings);
1558
1667
  }
1559
1668
 
1560
1669
  async function validateGitTarget(
@@ -1718,29 +1827,169 @@ async function validateNpmAuth(
1718
1827
  }
1719
1828
  }
1720
1829
 
1721
- function validateDockerTarget(
1830
+ async function validateDockerTarget(
1722
1831
  dockerTarget: Record<string, any>,
1832
+ smartconfigData: Record<string, any>,
1723
1833
  findings: IDoctorFinding[],
1724
- ): void {
1834
+ ): Promise<void> {
1835
+ if ("images" in dockerTarget) {
1836
+ findings.push({
1837
+ level: "error",
1838
+ message: "Docker release target still uses removed images config",
1839
+ fix: "Remove release.targets.docker.images and configure @git.zone/tsdocker instead.",
1840
+ });
1841
+ }
1842
+
1725
1843
  const enabled = dockerTarget.enabled ?? false;
1726
1844
  if (!enabled) {
1727
1845
  findings.push({ level: "ok", message: "Docker release target is disabled" });
1728
1846
  return;
1729
1847
  }
1730
1848
 
1731
- if (!Array.isArray(dockerTarget.images) || dockerTarget.images.length === 0) {
1849
+ if (dockerTarget.engine !== "tsdocker") {
1732
1850
  findings.push({
1733
1851
  level: "error",
1734
- message: "Docker release target is enabled without images",
1735
- fix: "Set release.targets.docker.images or disable release.targets.docker.enabled.",
1852
+ message: "Docker release target must use tsdocker",
1853
+ fix: "Set release.targets.docker.engine to tsdocker.",
1736
1854
  });
1737
- return;
1855
+ }
1856
+
1857
+ if (dockerTarget.patterns !== undefined && !Array.isArray(dockerTarget.patterns)) {
1858
+ findings.push({
1859
+ level: "error",
1860
+ message: "Docker release target patterns must be an array",
1861
+ fix: "Set release.targets.docker.patterns to an array of Dockerfile patterns or remove it.",
1862
+ });
1863
+ }
1864
+
1865
+ if (!isValidDockerParallel(dockerTarget.parallel)) {
1866
+ findings.push({
1867
+ level: "error",
1868
+ message: `Invalid tsdocker parallel setting: ${formatValue(dockerTarget.parallel)}`,
1869
+ fix: "Use false, true, or a positive concurrency number.",
1870
+ });
1871
+ }
1872
+
1873
+ const tsdockerConfig = smartconfigData["@git.zone/tsdocker"];
1874
+ if (!isPlainObject(tsdockerConfig)) {
1875
+ findings.push({
1876
+ level: "error",
1877
+ message: "Docker release target is enabled but @git.zone/tsdocker config is missing",
1878
+ fix: "Add @git.zone/tsdocker.registries and optional registryRepoMap/platforms config.",
1879
+ });
1880
+ } else {
1881
+ validateTsdockerProjectConfig(tsdockerConfig, findings);
1882
+ }
1883
+
1884
+ await validateTsdockerCommand(findings);
1885
+
1886
+ findings.push({
1887
+ level: "ok",
1888
+ message: `Docker release target uses tsdocker (${formatDockerPatterns(dockerTarget.patterns)})`,
1889
+ });
1890
+ }
1891
+
1892
+ function formatDockerPatterns(patterns: unknown): string {
1893
+ return Array.isArray(patterns) && patterns.length > 0
1894
+ ? patterns.map((pattern) => String(pattern)).join(", ")
1895
+ : "all Dockerfiles";
1896
+ }
1897
+
1898
+ function isPlainObject(value: unknown): value is Record<string, any> {
1899
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1900
+ }
1901
+
1902
+ function isValidDockerParallel(value: unknown): boolean {
1903
+ return value === undefined ||
1904
+ value === false ||
1905
+ value === true ||
1906
+ (typeof value === "number" && Number.isFinite(value) && value > 0);
1907
+ }
1908
+
1909
+ function validateTsdockerProjectConfig(
1910
+ tsdockerConfig: Record<string, any>,
1911
+ findings: IDoctorFinding[],
1912
+ ): void {
1913
+ const registries = Array.isArray(tsdockerConfig.registries)
1914
+ ? tsdockerConfig.registries
1915
+ : [];
1916
+ if (registries.length === 0) {
1917
+ findings.push({
1918
+ level: "error",
1919
+ message: "@git.zone/tsdocker.registries is empty",
1920
+ fix: "Set @git.zone/tsdocker.registries to registry hosts such as registry.gitlab.com.",
1921
+ });
1922
+ }
1923
+
1924
+ for (const registry of registries) {
1925
+ if (typeof registry !== "string" || !registry.trim()) {
1926
+ findings.push({
1927
+ level: "error",
1928
+ message: `Invalid tsdocker registry: ${formatValue(registry)}`,
1929
+ fix: "Use registry hosts such as registry.gitlab.com.",
1930
+ });
1931
+ continue;
1932
+ }
1933
+ if (registry.startsWith("http://") || registry.startsWith("https://")) {
1934
+ findings.push({
1935
+ level: "error",
1936
+ message: `tsdocker registry must not include a protocol: ${registry}`,
1937
+ fix: `Use ${registry.replace(/^https?:\/\//, "")}`,
1938
+ });
1939
+ }
1940
+ }
1941
+
1942
+ const registryRepoMap = tsdockerConfig.registryRepoMap;
1943
+ if (registryRepoMap !== undefined && !isPlainObject(registryRepoMap)) {
1944
+ findings.push({
1945
+ level: "error",
1946
+ message: "@git.zone/tsdocker.registryRepoMap must be an object",
1947
+ });
1948
+ } else if (isPlainObject(registryRepoMap)) {
1949
+ for (const registry of Object.keys(registryRepoMap)) {
1950
+ if (registry.startsWith("http://") || registry.startsWith("https://")) {
1951
+ findings.push({
1952
+ level: "error",
1953
+ message: `tsdocker registryRepoMap key must not include a protocol: ${registry}`,
1954
+ fix: `Use ${registry.replace(/^https?:\/\//, "")}`,
1955
+ });
1956
+ }
1957
+ }
1738
1958
  }
1739
1959
 
1740
1960
  findings.push({
1741
1961
  level: "ok",
1742
- message: `Docker release target has ${dockerTarget.images.length} image template(s)`,
1962
+ message: `@git.zone/tsdocker has ${registries.length} registries`,
1963
+ });
1964
+ }
1965
+
1966
+ async function validateTsdockerCommand(findings: IDoctorFinding[]): Promise<void> {
1967
+ const smartshellInstance = new plugins.smartshell.Smartshell({
1968
+ executor: "bash",
1969
+ sourceFilePaths: [],
1743
1970
  });
1971
+ try {
1972
+ const result = await smartshellInstance.execSpawn(
1973
+ "tsdocker",
1974
+ ["--version"],
1975
+ { silent: true, timeout: 8000 },
1976
+ );
1977
+ if (result.exitCode === 0) {
1978
+ findings.push({ level: "ok", message: "tsdocker command is available" });
1979
+ } else {
1980
+ findings.push({
1981
+ level: "error",
1982
+ message: "tsdocker command is not available",
1983
+ fix: "Install @git.zone/tsdocker globally or make it available on PATH.",
1984
+ });
1985
+ }
1986
+ } catch (error) {
1987
+ findings.push({
1988
+ level: "error",
1989
+ message: "Could not execute tsdocker",
1990
+ fix: error instanceof Error ? error.message : String(error),
1991
+ });
1992
+ }
1744
1993
  }
1745
1994
 
1746
1995
  async function validateDetectedProjectType(
@@ -1795,7 +2044,7 @@ export function showHelp(mode?: ICliMode): void {
1795
2044
  { name: "cli", description: "Configure CLI behavior interactively" },
1796
2045
  { name: "release", description: "Configure release workflow interactively" },
1797
2046
  { name: "doctor", description: "Validate .smartconfig.json" },
1798
- { name: "fix [instructions]", description: "Use opencode to repair .smartconfig.json" },
2047
+ { name: "fix [instructions]", description: "Repair .smartconfig.json" },
1799
2048
  { name: "get <path>", description: "Read a single config value" },
1800
2049
  { name: "set <path> <value>", description: "Write a config value" },
1801
2050
  { name: "unset <path>", description: "Delete a config value" },
@@ -1840,7 +2089,7 @@ export function showHelp(mode?: ICliMode): void {
1840
2089
  console.log(" cli Configure CLI behavior interactively");
1841
2090
  console.log(" release Configure release workflow interactively");
1842
2091
  console.log(" doctor Validate .smartconfig.json");
1843
- console.log(" fix [instructions] Use opencode to repair .smartconfig.json");
2092
+ console.log(" fix [instructions] Repair .smartconfig.json");
1844
2093
  console.log(" get <path> Read a single config value");
1845
2094
  console.log(" set <path> <value> Write a config value");
1846
2095
  console.log(" unset <path> Delete a config value");
@@ -1,6 +1,10 @@
1
1
  import * as plugins from './mod.plugins.js';
2
2
  import { FormatContext } from './classes.formatcontext.js';
3
- import type { IPlannedChange, ICheckResult } from './interfaces.format.js';
3
+ import type {
4
+ IPlannedChange,
5
+ ICheckResult,
6
+ IFormatWarning,
7
+ } from './interfaces.format.js';
4
8
  import { Project } from '../classes.project.js';
5
9
  import { FormatStats } from './classes.formatstats.js';
6
10
 
@@ -19,6 +23,14 @@ export abstract class BaseFormatter {
19
23
  abstract analyze(): Promise<IPlannedChange[]>;
20
24
  abstract applyChange(change: IPlannedChange): Promise<void>;
21
25
 
26
+ get runsWithoutChanges(): boolean {
27
+ return false;
28
+ }
29
+
30
+ async validate(): Promise<IFormatWarning[]> {
31
+ return [];
32
+ }
33
+
22
34
  async execute(changes: IPlannedChange[]): Promise<void> {
23
35
  const startTime = this.stats.moduleStartTime(this.name);
24
36
  this.stats.startModule(this.name);