agentic-workflow-manager 9.22.0 → 9.22.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 (53) hide show
  1. package/dist/src/commands/change/index.js +2 -2
  2. package/dist/src/commands/init.js +8 -4
  3. package/dist/src/commands/registry/add.js +9 -3
  4. package/dist/src/commands/sync.js +11 -5
  5. package/dist/src/commands/update.js +23 -12
  6. package/dist/src/core/changes/derive.js +6 -3
  7. package/dist/src/core/changes/git.js +1 -2
  8. package/dist/src/core/changes/worktree.js +15 -3
  9. package/dist/src/core/init/steps.js +3 -8
  10. package/dist/src/core/registries.js +20 -15
  11. package/dist/src/core/registry-git-errors.js +107 -0
  12. package/dist/src/core/registry-git.js +93 -0
  13. package/dist/src/core/registry-sync-report.js +74 -0
  14. package/dist/src/core/versioning.js +3 -6
  15. package/dist/src/index.js +5 -8
  16. package/dist/tests/commands/change/new.test.js +103 -57
  17. package/dist/tests/commands/change/validate.test.js +164 -108
  18. package/dist/tests/commands/changes/list.test.js +41 -22
  19. package/dist/tests/commands/job/gate-reconcile.test.js +13 -2
  20. package/dist/tests/commands/registry-sync-honesty.test.js +175 -0
  21. package/dist/tests/commands/sensors/exec.test.js +3 -1
  22. package/dist/tests/commands/update.test.js +8 -8
  23. package/dist/tests/commands/watch/supervisor-loop.test.js +21 -5
  24. package/dist/tests/core/changes/helpers.js +15 -3
  25. package/dist/tests/core/changes/list-paging.test.js +131 -110
  26. package/dist/tests/core/changes/list.test.js +133 -83
  27. package/dist/tests/core/changes/receipt.test.js +18 -6
  28. package/dist/tests/core/diagnostics/provider-checks.test.js +3 -0
  29. package/dist/tests/core/init/steps-registry-sync.test.js +6 -8
  30. package/dist/tests/core/registry-git.test.js +153 -0
  31. package/dist/tests/helpers/commit-seeded-registry.js +18 -0
  32. package/dist/tests/integration/codex-provider-isolated.test.js +7 -2
  33. package/dist/tests/integration/copilot-init-isolated.test.js +7 -4
  34. package/dist/tests/structural/active-documentation.test.js +0 -41
  35. package/dist/tests/structural/bundle-skill-reference-contract.test.js +0 -12
  36. package/dist/tests/structural/cli-reference-matches-commander.test.js +94 -0
  37. package/dist/tests/structural/native-release-artifacts.test.js +116 -107
  38. package/dist/tests/structural/sensor-documentation-contract.test.js +0 -47
  39. package/dist/tests/structural/sensor-portability-contract.test.js +0 -7
  40. package/dist/tests/structural/simple-git-v4-pin.test.js +49 -0
  41. package/package.json +2 -2
  42. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  43. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  44. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  45. package/prebuilds/win32-x64/secure_fs.node +0 -0
  46. package/dist/tests/structural/cloud-admission-docs-contract.test.js +0 -54
  47. package/dist/tests/structural/documentation-phase-is-mechanized.test.js +0 -29
  48. package/dist/tests/structural/operator-environments-docs-contract.test.js +0 -70
  49. package/dist/tests/structural/process-model-description-contract.test.js +0 -34
  50. package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +0 -104
  51. package/dist/tests/structural/r4-plan-currentness-docs.test.js +0 -37
  52. package/dist/tests/structural/sensor-execution-optimization-docs-contract.test.js +0 -102
  53. package/dist/tests/structural/sensor-slice-decision-contract.test.js +0 -63
@@ -119,7 +119,7 @@ function changeNew(cwd, slug, rawOpts, deps) {
119
119
  if (!range.ok)
120
120
  return (0, command_1.failed)(range.failure);
121
121
  const { head } = range;
122
- const derived = (0, derive_1.deriveFields)(root, range.mergeBase);
122
+ const derived = (0, derive_1.deriveFields)(root, range.mergeBase, head);
123
123
  if (!derived.ok)
124
124
  return (0, command_1.fail)(derived.diagnostic.code, derived.diagnostic.message);
125
125
  const movedAfterDerive = (0, range_1.headMovedFailure)(root, head);
@@ -255,7 +255,7 @@ function failure(json, code, message, remedy) {
255
255
  return (0, command_1.fail)(code, message, remedy);
256
256
  return render(true, 'invalid', [finish(types_1.RECORD_DIR, [{ code, message: remedy ? `${message}; ${remedy}` : message }])]);
257
257
  }
258
- const deriveOnce = (run) => (run.derived ??= (0, derive_1.deriveFields)(run.root, run.mergeBaseSha));
258
+ const deriveOnce = (run) => (run.derived ??= (0, derive_1.deriveFields)(run.root, run.mergeBaseSha, run.head));
259
259
  function lateFiles(run, commit) {
260
260
  if (!run.late.has(commit)) {
261
261
  const raw = (0, git_1.changedFiles)(run.root, commit, run.head);
@@ -45,6 +45,7 @@ const doctor_1 = require("./doctor");
45
45
  const context_1 = require("../core/diagnostics/context");
46
46
  const bundles_1 = require("../core/bundles");
47
47
  const registries_1 = require("../core/registries");
48
+ const registry_sync_report_1 = require("../core/registry-sync-report");
48
49
  const orchestrator_1 = require("../core/init/orchestrator");
49
50
  const steps_1 = require("../core/init/steps");
50
51
  const failure_1 = require("../core/init/failure");
@@ -210,10 +211,13 @@ async function runInit(opts = {}) {
210
211
  (0, registries_1.seedBaselineRegistry)();
211
212
  if ((0, registries_1.registriesNeedSync)()) {
212
213
  // `syncRegistries()` reports per-registry failures as RESULTS,
213
- // never as throws (core/registries.ts) — swallowing them here
214
- // let an unavailable registry degrade silently into some later
215
- // step's failure instead of being reported as its own cause.
216
- (0, registries_1.assertSyncedRegistriesUsable)((await mergedActions.syncCache()) ?? []);
214
+ // never as throws (core/registries.ts). SG-7: init exits non-zero
215
+ // on any sync/clone failure (usable cache is not enough here).
216
+ const syncResults = (await mergedActions.syncCache()) ?? [];
217
+ if ((0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(syncResults)) {
218
+ throw new Error(`registry sync failed — ${(0, registries_1.describeRegistrySyncErrors)((0, registries_1.registrySyncErrors)(syncResults))}`);
219
+ }
220
+ (0, registries_1.assertSyncedRegistriesUsable)(syncResults);
217
221
  }
218
222
  const bundles = (0, bundles_1.discoverAllBundles)(undefined, reporter);
219
223
  const ctx = (0, context_1.gatherContext)({ cwd, bundles, agent });
@@ -8,8 +8,9 @@ exports.addRegistry = addRegistry;
8
8
  // cli/src/commands/registry/add.ts
9
9
  // Logic for `awm registry add`, separated from commander wiring (testable without prompts).
10
10
  const fs_1 = __importDefault(require("fs"));
11
- const simple_git_1 = __importDefault(require("simple-git"));
12
11
  const registries_1 = require("../../core/registries");
12
+ const registry_git_1 = require("../../core/registry-git");
13
+ const registry_git_errors_1 = require("../../core/registry-git-errors");
13
14
  const discovery_1 = require("../../core/discovery");
14
15
  const bundles_1 = require("../../core/bundles");
15
16
  const orchestrators_1 = require("../../core/orchestrators");
@@ -40,12 +41,17 @@ async function addRegistry(remote, nameOverride, reporter) {
40
41
  return { ok: false, name, error: `Destination already exists on disk: ${dest}` };
41
42
  }
42
43
  fs_1.default.mkdirSync((0, registries_1.registriesDir)(), { recursive: true });
44
+ (0, registry_git_1.warnIgnoredGitEnvOnce)();
43
45
  try {
44
- await (0, simple_git_1.default)().clone(remote, dest);
46
+ await (0, registry_git_1.createRegistryGit)().clone(remote, dest);
45
47
  }
46
48
  catch (e) {
47
49
  fs_1.default.rmSync(dest, { recursive: true, force: true });
48
- return { ok: false, name, error: `Clone failed: ${e instanceof Error ? e.message : String(e)}` };
50
+ return {
51
+ ok: false,
52
+ name,
53
+ error: (0, registry_git_errors_1.formatClassifiedRegistryFailure)({ name, remote, err: e }),
54
+ };
49
55
  }
50
56
  if (!(0, registries_1.validateRegistryLayout)(dest)) {
51
57
  fs_1.default.rmSync(dest, { recursive: true, force: true });
@@ -19,6 +19,7 @@ const picocolors_1 = __importDefault(require("picocolors"));
19
19
  const paths_1 = require("../core/paths");
20
20
  const profile_1 = require("../core/profile");
21
21
  const registries_1 = require("../core/registries");
22
+ const registry_sync_report_1 = require("../core/registry-sync-report");
22
23
  const bundles_1 = require("../core/bundles");
23
24
  const bundle_install_1 = require("../core/bundle-install");
24
25
  const profile_pins_1 = require("../core/profile-pins");
@@ -74,9 +75,14 @@ async function runSyncCore(options, deps = {}) {
74
75
  }
75
76
  const selectedAgents = resolved.targets;
76
77
  const syncResults = await d.syncRegistries();
77
- for (const r of syncResults) {
78
- if (r.action === 'error')
79
- console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
78
+ // SG-7: any sync/clone failure → non-zero exit. Keep later profile sync / link
79
+ // repair when usable content remains (audit A1) — aborting here left healthy
80
+ // registries advanced on disk without reconciling the project tree.
81
+ const syncHadErrors = (0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(syncResults);
82
+ if (syncHadErrors) {
83
+ for (const e of (0, registries_1.registrySyncErrors)(syncResults)) {
84
+ console.error(picocolors_1.default.red(` ✖ registry ${e.name}: ${e.error}`));
85
+ }
80
86
  }
81
87
  try {
82
88
  (0, registries_1.assertSyncedRegistriesUsable)(syncResults);
@@ -126,7 +132,7 @@ async function runSyncCore(options, deps = {}) {
126
132
  reportProjectLinkRepair(d.reconcileProjectSkillLinks(projectRoot, selectedAgents, (0, registries_2.contentRoots)()));
127
133
  if (profile.extensions.length === 0) {
128
134
  console.log(picocolors_1.default.yellow('No extensions in .awm/profile.json — nothing to sync. Use `awm add <bundle>` first.'));
129
- return { code: 0, selectedAgents };
135
+ return { code: syncHadErrors ? 1 : 0, selectedAgents };
130
136
  }
131
137
  const method = options.method === 'copy' ? 'copy' : 'symlink';
132
138
  let result;
@@ -150,5 +156,5 @@ async function runSyncCore(options, deps = {}) {
150
156
  for (const id of result.transactionIds) {
151
157
  console.log(picocolors_1.default.dim(` transaction ${id} — undo with \`awm backup restore ${id}\``));
152
158
  }
153
- return { code: 0, selectedAgents, result };
159
+ return { code: syncHadErrors ? 1 : 0, selectedAgents, result };
154
160
  }
@@ -26,6 +26,7 @@ const paths_1 = require("../core/paths");
26
26
  const config_1 = require("../utils/config");
27
27
  const agent_targets_1 = require("../core/agent-targets");
28
28
  const registries_1 = require("../core/registries");
29
+ const registry_sync_report_1 = require("../core/registry-sync-report");
29
30
  const paths_2 = require("../core/paths");
30
31
  const regenerate_1 = require("../core/context/regenerate");
31
32
  const reconciliation_1 = require("../core/reconciliation");
@@ -59,11 +60,14 @@ function updateOutro(result) {
59
60
  const { registries: reg } = result;
60
61
  if (reg.configured === 0)
61
62
  return 'Nothing updated — no registries configured on this machine.';
62
- if (result.code !== 0)
63
+ // SG-7: sync errors always yield code ≠ 0. When healthy registries still advanced,
64
+ // later stages ran — say so instead of the unreachable pre-SG-7 "stale content / exit 0".
65
+ if (result.code !== 0) {
66
+ if (reg.failed.length > 0 && reg.synced > 0) {
67
+ return (`⚠ Finished with registry sync errors — ${reg.synced}/${reg.configured} registries synced `
68
+ + `(failed: ${reg.failed.join(', ')}); see errors above.`);
69
+ }
63
70
  return 'Update failed — see errors above.';
64
- if (reg.failed.length > 0) {
65
- return `⚠ Updated with stale content — ${reg.synced}/${reg.configured} registries synced, `
66
- + `kept on-disk content for: ${reg.failed.join(', ')}.`;
67
71
  }
68
72
  return `✅ ${reg.synced} ${reg.synced === 1 ? 'registry' : 'registries'}, skills and hooks updated.`;
69
73
  }
@@ -90,8 +94,10 @@ async function runUpdateCore(options = {}, deps = {}) {
90
94
  const selectedAgents = resolved.targets;
91
95
  const registryResults = await d.syncRegistries();
92
96
  for (const r of registryResults) {
93
- if (r.action === 'error')
94
- console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
97
+ if (r.action === 'error') {
98
+ // Printed below as a hard SG-7 failure — skip the stale warn path.
99
+ continue;
100
+ }
95
101
  else if (r.disposition === 'regressed') {
96
102
  const latest = r.availableVersion && r.availableVersion !== r.version
97
103
  ? ` Latest available: ${r.availableVersion}.`
@@ -131,11 +137,16 @@ async function runUpdateCore(options = {}, deps = {}) {
131
137
  console.error(picocolors_1.default.dim(" This machine was never initialized: run 'awm init' first."));
132
138
  return { code: 1, selectedAgents, registries };
133
139
  }
134
- // Falla CERRADO solo cuando el registry quedó sin contenido en disco: ahí lo que sigue
135
- // leería un árbol inexistente y el fallo reaparecería más tarde, disfrazado de otra
136
- // etapa. Un registry que falló pero conserva su contenido queda stale, no roto — se
137
- // sigue (doctrina de `unusableSyncedRegistries`: un registry secundario flaky nunca
138
- // aborta la corrida) y el cierre lo nombra en vez de declarar éxito parejo.
140
+ // SG-7: any sync/clone failure → non-zero exit with classified message.
141
+ // Do NOT abort the later stages early when healthy registries already advanced
142
+ // on disk (audit A1): reconcile / hooks / self-update still run against usable
143
+ // content, then the command exits ≠ 0 naming the failed registry.
144
+ const syncHadErrors = (0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(registryResults);
145
+ if (syncHadErrors) {
146
+ for (const e of (0, registries_1.registrySyncErrors)(registryResults)) {
147
+ console.error(picocolors_1.default.red(` ✖ registry ${e.name}: ${e.error}`));
148
+ }
149
+ }
139
150
  try {
140
151
  (0, registries_1.assertSyncedRegistriesUsable)(registryResults);
141
152
  }
@@ -188,5 +199,5 @@ async function runUpdateCore(options = {}, deps = {}) {
188
199
  // pasa `undefined` a propósito, para que la decisión la tome `defaultSelfUpdateMode()`
189
200
  // según haya o no un humano en stdin — no este llamador.
190
201
  await d.offerSelfUpdate(options.yes === true ? 'assume-yes' : undefined);
191
- return { code: 0, selectedAgents, registries };
202
+ return { code: syncHadErrors ? 1 : 0, selectedAgents, registries };
192
203
  }
@@ -96,13 +96,16 @@ const fail = (code, message) => ({ ok: false, diagnostic: { code, message: (0, t
96
96
  * conventional commit does), while `semver` and `breaking` cover every commit.
97
97
  * `mergeBaseSha` must be a full commit id (the output of `mergeBase`); anything else is `invalid-input`.
98
98
  */
99
- function deriveFields(root, mergeBaseSha) {
99
+ function deriveFields(root, mergeBaseSha, pinnedHead) {
100
100
  if (typeof root !== 'string' || root.length === 0)
101
101
  return fail('invalid-input', 'repository root must be a non-empty string');
102
102
  if (typeof mergeBaseSha !== 'string' || !types_1.COMMIT_ID.test(mergeBaseSha))
103
103
  return fail('invalid-input', `merge-base must be a full commit id, got "${(0, text_safety_1.esc)(mergeBaseSha)}"`);
104
- // HEAD is read once: the log and the diff below both use this sha, so a HEAD that moves meanwhile cannot mix snapshots.
105
- const head = (0, git_1.headSha)(root);
104
+ if (pinnedHead !== undefined && (typeof pinnedHead !== 'string' || !types_1.COMMIT_ID.test(pinnedHead)))
105
+ return fail('invalid-input', `pinned HEAD must be a full commit id, got "${(0, text_safety_1.esc)(pinnedHead)}"`);
106
+ // HEAD is read once: the log and the diff below both use this sha, so a HEAD that moves meanwhile cannot mix snapshots. A
107
+ // command that already pinned HEAD passes it (and checks `headMovedFailure` afterwards), saving one more `rev-parse`.
108
+ const head = pinnedHead ?? (0, git_1.headSha)(root);
106
109
  if (head === null)
107
110
  return fail('git-failed', 'git could not read HEAD');
108
111
  const range = `${mergeBaseSha}..${head}`;
@@ -171,8 +171,7 @@ function isCommittedClean(root, rel) {
171
171
  * Unknown state, or an unsafe exclusion, counts as dirty: an unreadable repository must never look clean.
172
172
  */
173
173
  function isDirtyOutside(root, extraExcludes) {
174
- const state = (0, worktree_1.gitState)(root, true, extraExcludes);
175
- return state.kind !== 'ok' || state.dirty;
174
+ return (0, worktree_1.treeDirty)(root, true, extraExcludes) !== false;
176
175
  }
177
176
  /** Repository root with native separators; null outside a repository. Only `rev-parse`: never a (slow, refreshing) `status`. */
178
177
  function repoRoot(cwd) {
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.REASON_GIT_FAILED = exports.REASON_NOT_REPO = exports.RUNTIME_STATE_EXCLUDES = void 0;
7
7
  exports.gitState = gitState;
8
+ exports.treeDirty = treeDirty;
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const git_env_1 = require("./git-env");
10
11
  const text_safety_1 = require("./text-safety");
@@ -39,15 +40,26 @@ function gitState(cwd, untracked, extraExcludes = []) {
39
40
  catch (error) {
40
41
  return gitFailure(error);
41
42
  }
43
+ const dirty = treeDirty(root, untracked, extraExcludes);
44
+ return dirty === null ? { kind: 'failed' } : { kind: 'ok', root, commit, dirty };
45
+ }
46
+ /**
47
+ * Only the dirty measure of `gitState`, for a caller that already knows the repository root and does not need HEAD: one
48
+ * `status` instead of three git processes. Null when git fails or an exclusion is unsafe (the caller fails closed). Never throws.
49
+ */
50
+ function treeDirty(root, untracked, extraExcludes = []) {
51
+ if (typeof root !== 'string' || root.length === 0)
52
+ return null;
53
+ if (!Array.isArray(extraExcludes) || extraExcludes.length > MAX_EXTRA_EXCLUDES || !extraExcludes.every(text_safety_1.isSafeRelativePath))
54
+ return null;
42
55
  try {
43
56
  // Submodules are never hidden by config (`.gitmodules` ignore=all, diff.ignoreSubmodules). The pre-run measure counts everything
44
57
  // (`none`); the post-run recheck is about tracked files only, so it also ignores untracked content inside a submodule
45
58
  // (sensor build/coverage output) while still seeing a moved checkout or modified tracked content (`untracked`).
46
59
  const args = ['status', '--porcelain', untracked ? '--untracked-files=normal' : '--untracked-files=no', untracked ? '--ignore-submodules=none' : '--ignore-submodules=untracked', '--', '.', ...exports.RUNTIME_STATE_EXCLUDES.map((p) => `:(exclude)${p}`), ...extraExcludes.map((p) => `:(exclude,literal)${p}`)];
47
- const status = (0, git_env_1.execGit)(root, args, { stderr: 'ignore', maxBuffer: MAX_GIT_OUTPUT });
48
- return { kind: 'ok', root, commit, dirty: status.trim().length > 0 };
60
+ return (0, git_env_1.execGit)(root, args, { stderr: 'ignore', maxBuffer: MAX_GIT_OUTPUT }).trim().length > 0;
49
61
  }
50
62
  catch {
51
- return { kind: 'failed' };
63
+ return null;
52
64
  }
53
65
  }
@@ -162,21 +162,16 @@ async function stepCache(d) {
162
162
  return failed('machine.cache', 'machine', msg);
163
163
  }
164
164
  // `syncRegistries()` reports per-registry failures as RESULTS, not throws
165
- // (registries.ts), so the `try` above catches none of them. Ignoring them
166
- // is what let an unavailable base registry degrade silently into some
167
- // later step's failure — with its own cause already gone.
165
+ // (registries.ts), so the `try` above catches none of them. SG-7: init must
166
+ // fail closed on ANY sync/clone failure (usable on-disk cache is not enough).
168
167
  const errors = (0, registries_1.registrySyncErrors)(results);
169
168
  if (errors.length === 0)
170
169
  return ok('machine.cache', 'machine', 'applied');
171
- // A registry that errored and has no content on disk is unusable, and every
172
- // later step that reads it will fail for a reason that no longer names this
173
- // cause — so machine.cache owns it here. One that errored but still has
174
- // content is stale, not broken: record it and carry on.
175
170
  const unusable = (0, registries_1.unusableSyncedRegistries)(results);
176
171
  if (unusable.length > 0) {
177
172
  return failed('machine.cache', 'machine', `registry unavailable — ${(0, registries_1.describeRegistrySyncErrors)(unusable)}`);
178
173
  }
179
- return ok('machine.cache', 'machine', 'applied', `stale registries — ${(0, registries_1.describeRegistrySyncErrors)(errors)}`);
174
+ return failed('machine.cache', 'machine', `registry sync failed — ${(0, registries_1.describeRegistrySyncErrors)(errors)}`);
180
175
  }
181
176
  /** Step 2 – Install the session-start hook for the target agent. */
182
177
  function stepHook(d) {
@@ -30,8 +30,9 @@ exports.assertRegistryGates = assertRegistryGates;
30
30
  // src/core/registries.ts
31
31
  const fs_1 = __importDefault(require("fs"));
32
32
  const path_1 = __importDefault(require("path"));
33
- const simple_git_1 = __importDefault(require("simple-git"));
34
33
  const registry_1 = require("./registry");
34
+ const registry_git_1 = require("./registry-git");
35
+ const registry_git_errors_1 = require("./registry-git-errors");
35
36
  const versioning_1 = require("./versioning");
36
37
  const cli_version_1 = require("./cli-version");
37
38
  const paths_1 = require("./paths");
@@ -229,8 +230,10 @@ function registriesNeedSync() {
229
230
  return listRegistries().some((registry) => !validateRegistryLayout(registry.contentRoot));
230
231
  }
231
232
  /** Sincroniza cada registry configurado al ref resuelto (pin > último tag > HEAD);
232
- * re-clona si falta el dir. Errores por-registry NO fatales: se reportan en el resultado. */
233
+ * re-clona si falta el dir. Errores por-registry se reportan en el resultado (clasificados SG-6);
234
+ * si el llamador debe abortar lo decide la política SG-7 del comando. */
233
235
  async function syncRegistries() {
236
+ (0, registry_git_1.warnIgnoredGitEnvOnce)();
234
237
  const results = [];
235
238
  for (const reg of listRegistries()) {
236
239
  try {
@@ -241,7 +244,7 @@ async function syncRegistries() {
241
244
  if (freshClone) {
242
245
  fs_1.default.mkdirSync(registriesDir(), { recursive: true });
243
246
  try {
244
- await (0, simple_git_1.default)().clone(reg.remote, reg.contentRoot);
247
+ await (0, registry_git_1.createRegistryGit)().clone(reg.remote, reg.contentRoot);
245
248
  }
246
249
  catch (e) {
247
250
  fs_1.default.rmSync(reg.contentRoot, { recursive: true, force: true });
@@ -249,9 +252,9 @@ async function syncRegistries() {
249
252
  }
250
253
  }
251
254
  else {
252
- await (0, simple_git_1.default)(reg.contentRoot).reset(['--hard']);
255
+ await (0, registry_git_1.createRegistryGit)(reg.contentRoot).reset(['--hard']);
253
256
  }
254
- const git = (0, simple_git_1.default)(reg.contentRoot);
257
+ const git = (0, registry_git_1.createRegistryGit)(reg.contentRoot);
255
258
  const previousVersion = freshClone ? null : await (0, versioning_1.currentVersion)(reg.contentRoot);
256
259
  const versionOpts = (0, versioning_1.machineVersionOpts)(reg.name);
257
260
  let resolved;
@@ -290,7 +293,11 @@ async function syncRegistries() {
290
293
  });
291
294
  }
292
295
  catch (e) {
293
- results.push({ name: reg.name, action: 'error', error: e instanceof Error ? e.message : String(e) });
296
+ results.push({
297
+ name: reg.name,
298
+ action: 'error',
299
+ error: (0, registry_git_errors_1.formatClassifiedRegistryFailure)({ name: reg.name, remote: reg.remote, err: e }),
300
+ });
294
301
  }
295
302
  }
296
303
  return results;
@@ -309,15 +316,13 @@ function describeRegistrySyncErrors(errors) {
309
316
  * Registries that both errored during sync AND no longer have a usable content
310
317
  * layout afterwards — i.e. genuinely unusable, as opposed to merely stale.
311
318
  *
312
- * `syncRegistries()` deliberately reports per-registry failures as results
313
- * rather than throwing, so a flaky secondary registry never aborts a whole
314
- * run. Callers that go on to READ registry content (init) still need to know
315
- * whether what they are about to read remains usable: otherwise a missing,
316
- * empty, or unsafe registry
317
- * resurfaces much later as an unrelated step's failure, with its real cause
318
- * already discarded. Note that "usable" is deliberately about content on disk,
319
- * not about being a healthy git clone — a seeded content root with no `.git`
320
- * fails to sync every time and is still perfectly readable.
319
+ * `syncRegistries()` still reports per-registry failures as results rather than
320
+ * throwing (so callers can classify/format each one). Under SG-7, init / update /
321
+ * sync / registry add exit non-zero on ANY sync error; add / list may continue
322
+ * only when cached content remains usable — and this helper is what those soft
323
+ * paths consult. "Usable" is about content on disk, not a healthy git clone —
324
+ * a seeded content root with no `.git` fails to sync every time and is still
325
+ * readable.
321
326
  */
322
327
  function unusableSyncedRegistries(results) {
323
328
  const errors = registrySyncErrors(results);
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.redactRemoteForDisplay = redactRemoteForDisplay;
4
+ exports.extractFatalLine = extractFatalLine;
5
+ exports.classifyRegistryGitError = classifyRegistryGitError;
6
+ exports.formatClassifiedRegistryFailure = formatClassifiedRegistryFailure;
7
+ const registry_git_1 = require("./registry-git");
8
+ const text_safety_1 = require("./changes/text-safety");
9
+ const REMEDIES = {
10
+ authentication: 'configure credential.helper (awm ignores ambient GIT_ASKPASS)',
11
+ tls: 'git config --global http.sslCAInfo <path> or http.sslCAPath (awm ignores ambient GIT_SSL_*)',
12
+ network: 'check connectivity / proxy via git config --global http.proxy (awm does not pass ambient GIT_* proxy vars)',
13
+ ssh: 'set core.sshCommand or ~/.ssh/config (awm ignores ambient GIT_SSH_COMMAND)',
14
+ 'not-found': 'verify the remote URL and that the repository exists and is reachable',
15
+ // simple-git `timeout.block` resets on stdout/stderr activity — idle bound, not wall-clock.
16
+ // Only the plugin's "block timeout reached" is this class (audit M1); git/curl "timed out" → network.
17
+ timeout: `check network connectivity; registry git children are killed after ${registry_git_1.REGISTRY_GIT_TIMEOUT_MS / 1000}s `
18
+ + 'without stdout/stderr activity',
19
+ };
20
+ /**
21
+ * Strip credential-bearing userinfo from a remote URL (or URL-shaped substring) before
22
+ * echoing it to operators. HTTPS `user:token@host` / `user@host` → scheme://host… only.
23
+ * Does not JSON-escape or clip — callers run `cleanText`/`clipMessage`/`esc` for echo safety.
24
+ */
25
+ function redactRemoteForDisplay(remote) {
26
+ if (typeof remote !== 'string' || !remote)
27
+ return '';
28
+ // https://user:pass@host/... or https://user@host/...
29
+ return remote.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/gi, '$1');
30
+ }
31
+ function errorText(err) {
32
+ if (err instanceof Error)
33
+ return err.message;
34
+ return String(err);
35
+ }
36
+ /** Extract the last line that looks like git's `fatal: …` diagnostic. */
37
+ function extractFatalLine(text) {
38
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
39
+ for (let i = lines.length - 1; i >= 0; i--) {
40
+ if (/^fatal:/i.test(lines[i]))
41
+ return lines[i];
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Map a registry git failure to a stable class + file-based remedy (SG-6).
47
+ * Heuristics are substring-based on message/stderr; never includes env values.
48
+ */
49
+ function classifyRegistryGitError(err) {
50
+ const message = errorText(err);
51
+ const fatalLine = extractFatalLine(message);
52
+ let errorClass;
53
+ // Audit M1: only simple-git's idle-block plugin text is `timeout`. Git/curl
54
+ // "Connection timed out" / "timed out" are connectivity → `network`.
55
+ if (/block timeout reached/i.test(message)) {
56
+ errorClass = 'timeout';
57
+ }
58
+ else if (/ssl|tls|certificate|unable to get local issuer|certificate verify failed/i.test(message)) {
59
+ errorClass = 'tls';
60
+ }
61
+ else if (
62
+ // Score repository-shaped not-found BEFORE broad SSH wording — GitHub SSH
63
+ // often pairs "Repository not found" with "Could not read from remote repository".
64
+ /repository not found|remote:\s*.*not found|fatal:\s*repository .+ not found|404\b/i.test(message)) {
65
+ errorClass = 'not-found';
66
+ }
67
+ else if (/host key verification|permission denied \(publickey\)/i.test(message)) {
68
+ errorClass = 'ssh';
69
+ }
70
+ else if (
71
+ // Remaining SSH transport wording — but DNS/timeout under ssh: belongs to network.
72
+ /\bssh\b/i.test(message)
73
+ && !/could not resolve|name or service not known|timed?\s*out|connection timed out|terminal prompts disabled|authentication failed|could not read username/i.test(message)) {
74
+ errorClass = 'ssh';
75
+ }
76
+ else if (/terminal prompts disabled|authentication failed|could not read username|auth(entication)? (fail|required)|401\b|403\b|invalid credentials/i.test(message)) {
77
+ errorClass = 'authentication';
78
+ }
79
+ else if (/could not resolve host|could not resolve hostname|failed to connect|connection refused|network is unreachable|unable to connect|proxy|name or service not known|timed?\s*out|connection timed out/i.test(message)) {
80
+ errorClass = 'network';
81
+ }
82
+ else {
83
+ // Default: treat unknown transport failures as network.
84
+ errorClass = 'network';
85
+ }
86
+ const rawFatal = fatalLine ?? (message.trim() ? message.trim().split(/\r?\n/).pop().slice(0, 240) : null);
87
+ return {
88
+ class: errorClass,
89
+ fatalLine: rawFatal === null ? null : redactRemoteForDisplay(rawFatal),
90
+ remedy: REMEDIES[errorClass],
91
+ };
92
+ }
93
+ /**
94
+ * Operator-facing SG-6 line: registry name, remote when known, class, fatal, remedy.
95
+ * Never includes ambient env values or URL userinfo credentials.
96
+ */
97
+ function formatClassifiedRegistryFailure(opts) {
98
+ const classified = classifyRegistryGitError(opts.err);
99
+ const remotePart = opts.remote
100
+ ? `; remote=${(0, text_safety_1.clipMessage)((0, text_safety_1.cleanText)(redactRemoteForDisplay(opts.remote)))}`
101
+ : '';
102
+ const fatalPart = classified.fatalLine
103
+ ? `; fatal: ${(0, text_safety_1.clipMessage)((0, text_safety_1.cleanText)(classified.fatalLine.replace(/^fatal:\s*/i, '')))}`
104
+ : '';
105
+ return (`registry ${(0, text_safety_1.esc)(opts.name)} sync failed (class=${classified.class}${remotePart}${fatalPart}); `
106
+ + `remedy: ${classified.remedy}`);
107
+ }
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REGISTRY_GIT_TIMEOUT_MS = void 0;
4
+ exports.buildRegistryGitEnv = buildRegistryGitEnv;
5
+ exports.listIgnoredGuardedGitEnvNames = listIgnoredGuardedGitEnvNames;
6
+ exports.formatIgnoredGitEnvNotice = formatIgnoredGitEnvNotice;
7
+ exports.resetIgnoredGitEnvNoticeForTests = resetIgnoredGitEnvNoticeForTests;
8
+ exports.warnIgnoredGitEnvOnce = warnIgnoredGitEnvOnce;
9
+ exports.createRegistryGit = createRegistryGit;
10
+ const simple_git_1 = require("simple-git");
11
+ exports.REGISTRY_GIT_TIMEOUT_MS = 60_000;
12
+ /**
13
+ * Non-`GIT_*` keys that simple-git 4 treats as guarded when supplied via `.env()`.
14
+ * Omit them from the copy so `.env(copy)` does not reject (editor/pager/visual family).
15
+ * Kept in sync with `@simple-git/argv-parser` GitEnvKeys non-prefix entries.
16
+ */
17
+ const NON_PREFIX_GUARDED_ENV = new Set(['editor', 'pager', 'prefix', 'ssh_askpass', 'visual']);
18
+ /**
19
+ * Ambient names that affect registry transport/auth and are stripped by the factory.
20
+ * SG-5 warns only for these (audit M2) — not GIT_EDITOR / GIT_PAGER / GIT_AUTHOR_*.
21
+ * Includes SSH_ASKPASS (audit B2), which simple-git also guards without a GIT_ prefix.
22
+ */
23
+ function isTransportRelevantIgnoredEnv(key) {
24
+ const lower = key.toLowerCase();
25
+ if (lower === 'ssh_askpass')
26
+ return true;
27
+ if (!lower.startsWith('git_'))
28
+ return false;
29
+ if (lower === 'git_terminal_prompt')
30
+ return false;
31
+ return (lower.startsWith('git_ssl_')
32
+ || lower === 'git_askpass'
33
+ || lower.startsWith('git_ssh')
34
+ || lower.startsWith('git_proxy')
35
+ || lower.startsWith('git_http_')
36
+ || lower.startsWith('git_curl_')
37
+ || lower.startsWith('git_config'));
38
+ }
39
+ /** Copy parent env without guarded GIT_* / editor keys, then force non-interactive prompts. */
40
+ function buildRegistryGitEnv(parent = process.env) {
41
+ const env = {};
42
+ for (const [key, value] of Object.entries(parent)) {
43
+ if (value === undefined)
44
+ continue;
45
+ const lower = key.toLowerCase();
46
+ if (lower.startsWith('git_'))
47
+ continue;
48
+ if (NON_PREFIX_GUARDED_ENV.has(lower))
49
+ continue;
50
+ env[key] = value;
51
+ }
52
+ env.GIT_TERMINAL_PROMPT = '0';
53
+ return env;
54
+ }
55
+ /** Names of stripped transport/auth vars worth warning about (SG-5) — never values. */
56
+ function listIgnoredGuardedGitEnvNames(parent = process.env) {
57
+ return Object.keys(parent)
58
+ .filter((key) => isTransportRelevantIgnoredEnv(key))
59
+ .sort((a, b) => a.localeCompare(b));
60
+ }
61
+ /** Operator notice for SG-5 — names only, never values. */
62
+ function formatIgnoredGitEnvNotice(names) {
63
+ const listed = names.join(', ');
64
+ return (`⚠ Ignoring ambient transport/auth env vars: ${listed}. `
65
+ + 'Use file-based git config instead '
66
+ + '(e.g. git config --global http.sslCAInfo <path>, credential.helper, core.sshCommand / ~/.ssh/config).');
67
+ }
68
+ let ignoredGitEnvWarned = false;
69
+ /** Test seam: clear the once-per-process SG-5 flag. */
70
+ function resetIgnoredGitEnvNoticeForTests() {
71
+ ignoredGitEnvWarned = false;
72
+ }
73
+ /**
74
+ * Before the first network registry git op: warn once listing ignored ambient
75
+ * transport/auth env names (except GIT_TERMINAL_PROMPT) and point at file-based remedies (SG-5).
76
+ */
77
+ function warnIgnoredGitEnvOnce(log = (message) => console.warn(message), parent = process.env) {
78
+ if (ignoredGitEnvWarned)
79
+ return;
80
+ const names = listIgnoredGuardedGitEnvNames(parent);
81
+ if (names.length === 0)
82
+ return;
83
+ ignoredGitEnvWarned = true;
84
+ log(formatIgnoredGitEnvNotice(names));
85
+ }
86
+ function createRegistryGit(baseDir) {
87
+ const git = (0, simple_git_1.simpleGit)({
88
+ ...(baseDir !== undefined ? { baseDir } : {}),
89
+ allowEnvironment: ['GIT_TERMINAL_PROMPT'],
90
+ timeout: { block: exports.REGISTRY_GIT_TIMEOUT_MS },
91
+ });
92
+ return git.env(buildRegistryGitEnv());
93
+ }
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatClassifiedRegistryFailure = void 0;
4
+ exports.formatRegistrySyncSoftFailure = formatRegistrySyncSoftFailure;
5
+ exports.hardFailOnAnyRegistrySyncError = hardFailOnAnyRegistrySyncError;
6
+ exports.formatSoftRegistrySyncFailures = formatSoftRegistrySyncFailures;
7
+ exports.reportRegistrySyncSoftFailures = reportRegistrySyncSoftFailures;
8
+ const registries_1 = require("./registries");
9
+ const registry_git_errors_1 = require("./registry-git-errors");
10
+ Object.defineProperty(exports, "formatClassifiedRegistryFailure", { enumerable: true, get: function () { return registry_git_errors_1.formatClassifiedRegistryFailure; } });
11
+ const text_safety_1 = require("./changes/text-safety");
12
+ const versioning_1 = require("./versioning");
13
+ /**
14
+ * Soft path for `awm add` / `awm list` (SG-7): continue on usable cache only after
15
+ * stating the cached version plus the classified cause/remedy.
16
+ * Avoids duplicating "registry X … sync failed" (audit B3); escapes the name like SG-6.
17
+ */
18
+ function formatRegistrySyncSoftFailure(input) {
19
+ const versionLabel = input.cachedVersion ? `v${input.cachedVersion}` : 'unknown';
20
+ return (`⚠ Continuing with cached version ${versionLabel} for registry ${(0, text_safety_1.esc)(input.name)}. `
21
+ + input.classifiedMessage);
22
+ }
23
+ /** True when any per-registry sync result is an error (SG-7 hard-fail gate). */
24
+ function hardFailOnAnyRegistrySyncError(results) {
25
+ return results.some((r) => r.action === 'error');
26
+ }
27
+ /**
28
+ * Soft-failure lines for add/list: one per errored registry that still has usable
29
+ * on-disk content. Callers print these then continue (or hard-fail via
30
+ * assertSyncedRegistriesUsable when nothing usable remains).
31
+ */
32
+ async function formatSoftRegistrySyncFailures(results, opts) {
33
+ const lines = [];
34
+ for (const r of results) {
35
+ if (r.action !== 'error')
36
+ continue;
37
+ if (!opts.isUsable(r.name))
38
+ continue;
39
+ lines.push(formatRegistrySyncSoftFailure({
40
+ name: r.name,
41
+ cachedVersion: await opts.cachedVersion(r.name),
42
+ classifiedMessage: r.error,
43
+ }));
44
+ }
45
+ return lines;
46
+ }
47
+ /**
48
+ * SG-7 soft path for `awm add` / `awm list`: warn with cached version + SG-6 text
49
+ * for each failed-but-usable registry; warn with the classified error for
50
+ * unusable ones (caller still runs assertSyncedRegistriesUsable for hard fail).
51
+ */
52
+ async function reportRegistrySyncSoftFailures(results, log = (m) => console.warn(m)) {
53
+ const roots = new Map((0, registries_1.listRegistries)().map((r) => [r.name, r.contentRoot]));
54
+ const soft = await formatSoftRegistrySyncFailures(results, {
55
+ isUsable: (name) => {
56
+ const root = roots.get(name);
57
+ return root !== undefined && (0, registries_1.validateRegistryLayout)(root);
58
+ },
59
+ cachedVersion: async (name) => {
60
+ const root = roots.get(name);
61
+ return root ? (0, versioning_1.currentVersion)(root) : null;
62
+ },
63
+ });
64
+ for (const line of soft)
65
+ log(line);
66
+ for (const r of results) {
67
+ if (r.action !== 'error')
68
+ continue;
69
+ const root = roots.get(r.name);
70
+ if (root !== undefined && (0, registries_1.validateRegistryLayout)(root))
71
+ continue;
72
+ log(` ⚠ registry ${r.name}: ${r.error}`);
73
+ }
74
+ }