@dzhechkov/harness-cli 0.8.20 → 0.8.22
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/.dz-manifest.json +12 -12
- package/README.md +61 -21
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +306 -25
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +1 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +10 -9
- package/sbom.json +11 -11
- package/src/cli.ts +322 -27
- package/src/known-flags.ts +1 -0
package/src/cli.ts
CHANGED
|
@@ -279,6 +279,8 @@ import {
|
|
|
279
279
|
isInsideTree,
|
|
280
280
|
signManifest,
|
|
281
281
|
verifyManifest,
|
|
282
|
+
hashPackBytes,
|
|
283
|
+
rewriteWorkspaceSpecs,
|
|
282
284
|
listPackFiles,
|
|
283
285
|
listSignablePackFiles,
|
|
284
286
|
assertKeyOutsideTree,
|
|
@@ -667,7 +669,7 @@ Usage:
|
|
|
667
669
|
dz upgrade [--target <name>] [--pubkey <path>] [--require-signing] (a TAMPERED pack aborts the upgrade)
|
|
668
670
|
dz sign --pack <dir> --key <path-outside-repo> (Ed25519 manifest + CycloneDX SBOM for a pack)
|
|
669
671
|
dz verify-pack --pack <dir> [--pubkey <path>] (signature check; fail-closed; key from the repo, never the pack)
|
|
670
|
-
dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
|
|
672
|
+
dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--mirror-cmd <cmd>|--no-mirror] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
|
|
671
673
|
dz release [--filter <name>] [--tag] [--publish] [--json] [--dry-run] [--no-issue] (VERIFIED release: 4 HARD gates in FRONT of dz publish — full package test suites, audit >=high, node --check of every dist/bin file, bin smoke-boot via "node <bin> --help" — any red gate STOPS the release (exit 1) + best-effort gh issue; all green ⇒ re-sign reminder, then prints the ready dz publish command (or chains with --publish); never duplicates publish's own gates)
|
|
672
674
|
dz parity [--target <name>] [--json] (the honest feature×target map, COMPUTED from the capability model — which harness feature is full / manual / absent on each of the ${TARGET_NAMES.length} targets, and via which form)
|
|
673
675
|
dz delivery-check --slug <slug> [--context-only] [--findings <f.json>] [--strict] [--author <model>] [--json] (portable Step-10 Delivery Gate: prints the 4-plane review brief + artifact probes; --findings classifies a fed-back review into a fail-closed ready|blocked hand-off and writes features/<slug>/10_delivery_review.md; --strict exits 1 on blocked)
|
|
@@ -839,6 +841,8 @@ export interface CliIo {
|
|
|
839
841
|
* without spawning anything.
|
|
840
842
|
*/
|
|
841
843
|
readonly releaseRunner?: ReleaseExecRunner;
|
|
844
|
+
/** Post-publish mirror command seam; production uses synchronous shell execution. */
|
|
845
|
+
readonly publishMirrorRunner?: PublishMirrorRunner;
|
|
842
846
|
/**
|
|
843
847
|
* Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
|
|
844
848
|
* it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
|
|
@@ -858,6 +862,21 @@ export type ReleaseExecRunner = (
|
|
|
858
862
|
opts: { readonly cwd: string; readonly timeoutMs: number },
|
|
859
863
|
) => { exitCode: number; stdout: string; stderr: string; timedOut?: boolean };
|
|
860
864
|
|
|
865
|
+
export type PublishMirrorRunner = (
|
|
866
|
+
command: string,
|
|
867
|
+
options: { readonly cwd: string; readonly env: NodeJS.ProcessEnv },
|
|
868
|
+
) => string;
|
|
869
|
+
|
|
870
|
+
type PublishMirrorState = {
|
|
871
|
+
readonly status: 'confirmed' | 'unconfirmed' | 'skipped' | 'not-configured';
|
|
872
|
+
readonly command: string;
|
|
873
|
+
readonly commit?: string;
|
|
874
|
+
readonly receipt?: { readonly manifestUrl: string; readonly confirmedAt: string; readonly waitedMs: number };
|
|
875
|
+
readonly error?: string;
|
|
876
|
+
readonly reason?: string;
|
|
877
|
+
readonly warning?: string;
|
|
878
|
+
};
|
|
879
|
+
|
|
861
880
|
interface ParsedArgs {
|
|
862
881
|
readonly command: string;
|
|
863
882
|
readonly options: Map<string, string>;
|
|
@@ -2986,6 +3005,8 @@ function cmdStatusline(
|
|
|
2986
3005
|
} else if (data.storeHealth?.verdict === 'unreadable') {
|
|
2987
3006
|
line += ` ⛔ STORE UNREADABLE${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
|
|
2988
3007
|
? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
|
|
3008
|
+
} else if (data.storeHealth?.verdict === 'busy') {
|
|
3009
|
+
line += ' ⏳ STORE BUSY';
|
|
2989
3010
|
} else if (data.storeHealth?.verdict === 'source-changed') {
|
|
2990
3011
|
line += ' ⚠ store source changed';
|
|
2991
3012
|
}
|
|
@@ -3634,13 +3655,16 @@ function learningStoreLine(
|
|
|
3634
3655
|
) + (reason ? ' [' + reason + ']' : '');
|
|
3635
3656
|
}
|
|
3636
3657
|
|
|
3637
|
-
function inspectLearningStore(
|
|
3658
|
+
function inspectLearningStore(
|
|
3659
|
+
projectRoot: string,
|
|
3660
|
+
countOptions?: { readonly busyTimeoutMs?: number; readonly attempts?: number },
|
|
3661
|
+
): {
|
|
3638
3662
|
mark: StoreMark | undefined;
|
|
3639
3663
|
health: StoreHealth;
|
|
3640
3664
|
rows: ReturnType<typeof countLearningStoreRowsReadonly>;
|
|
3641
3665
|
} {
|
|
3642
3666
|
const mark = readStoreMark(projectRoot);
|
|
3643
|
-
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3667
|
+
const rows = countLearningStoreRowsReadonly(projectRoot, countOptions);
|
|
3644
3668
|
return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
|
|
3645
3669
|
}
|
|
3646
3670
|
|
|
@@ -3737,7 +3761,8 @@ function refreshLearningStoreMark(
|
|
|
3737
3761
|
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3738
3762
|
const counts = observedRows(rows);
|
|
3739
3763
|
if (counts === undefined) {
|
|
3740
|
-
|
|
3764
|
+
const busy = rows.lexicalRows === 'busy' || rows.vectorRows === 'busy';
|
|
3765
|
+
writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — ${busy ? 'store busy; health not measured this run' : 'a store tier is unreadable'}`);
|
|
3741
3766
|
return;
|
|
3742
3767
|
}
|
|
3743
3768
|
writeStoreMark(projectRoot, {
|
|
@@ -3771,7 +3796,7 @@ function allowLearningStoreWrite(
|
|
|
3771
3796
|
): boolean {
|
|
3772
3797
|
let inspection: ReturnType<typeof inspectLearningStore>;
|
|
3773
3798
|
try {
|
|
3774
|
-
inspection = inspectLearningStore(projectRoot);
|
|
3799
|
+
inspection = inspectLearningStore(projectRoot, { busyTimeoutMs: 250, attempts: 3 });
|
|
3775
3800
|
} catch (error) {
|
|
3776
3801
|
const health: StoreHealth = {
|
|
3777
3802
|
verdict: 'unreadable',
|
|
@@ -3787,6 +3812,10 @@ function allowLearningStoreWrite(
|
|
|
3787
3812
|
// own row had landed.
|
|
3788
3813
|
return true;
|
|
3789
3814
|
}
|
|
3815
|
+
if (inspection.health.verdict === 'busy') {
|
|
3816
|
+
writeErr(`dz store guard: NOT MEASURED — ${inspection.health.reason}`);
|
|
3817
|
+
return true;
|
|
3818
|
+
}
|
|
3790
3819
|
if (inspection.health.verdict === 'source-changed') {
|
|
3791
3820
|
const counts = observedRows(inspection.rows);
|
|
3792
3821
|
if (counts === undefined) return false;
|
|
@@ -3826,6 +3855,7 @@ function warnLearningStoreRead(projectRoot: string, writeErr: WriteErr, command:
|
|
|
3826
3855
|
for (const line of lexicalSourceLines(rows)) writeErr(line);
|
|
3827
3856
|
return;
|
|
3828
3857
|
}
|
|
3858
|
+
if (health.verdict === 'busy') return;
|
|
3829
3859
|
const counts = observedRows(rows);
|
|
3830
3860
|
if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
|
|
3831
3861
|
&& (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
|
|
@@ -6855,7 +6885,37 @@ function cmdSbom(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
6855
6885
|
return 0;
|
|
6856
6886
|
}
|
|
6857
6887
|
|
|
6858
|
-
function
|
|
6888
|
+
function mirrorCommandFromConfig(cwd: string): { command?: string; warning?: string } {
|
|
6889
|
+
const configPath = join(cwd, '.dz', 'config.json');
|
|
6890
|
+
if (!existsSync(configPath)) return {};
|
|
6891
|
+
try {
|
|
6892
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8')) as { publish?: { mirrorCommand?: unknown } };
|
|
6893
|
+
const command = typeof config.publish?.mirrorCommand === 'string' ? config.publish.mirrorCommand.trim() : '';
|
|
6894
|
+
return command === '' ? {} : { command };
|
|
6895
|
+
} catch (error) {
|
|
6896
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
6897
|
+
return { warning: `.dz/config.json unreadable — ${reason}` };
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
|
|
6901
|
+
function mirrorShellToken(value: string): string {
|
|
6902
|
+
if (value === '') return "''";
|
|
6903
|
+
if (!/^[A-Za-z0-9@/.,+_-]+$/.test(value)) throw new Error('published package/version list is not shell-safe');
|
|
6904
|
+
return value;
|
|
6905
|
+
}
|
|
6906
|
+
|
|
6907
|
+
function mirrorFailureMessage(error: unknown): string {
|
|
6908
|
+
if (error instanceof Error && error.message.trim() !== '') return error.message.trim().split(/\r?\n/, 1)[0] ?? 'mirror command failed';
|
|
6909
|
+
return String(error);
|
|
6910
|
+
}
|
|
6911
|
+
|
|
6912
|
+
function cmdPublish(
|
|
6913
|
+
options: Map<string, string>,
|
|
6914
|
+
flags: Set<string>,
|
|
6915
|
+
cwd: string,
|
|
6916
|
+
writeOutput: Write,
|
|
6917
|
+
mirrorRunner?: PublishMirrorRunner,
|
|
6918
|
+
): number {
|
|
6859
6919
|
const json = flags.has('json');
|
|
6860
6920
|
// Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
|
|
6861
6921
|
// progress — goes to stderr instead of being dropped: a refusal that prints nothing is the silent
|
|
@@ -6863,9 +6923,9 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6863
6923
|
const write: Write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
|
|
6864
6924
|
// Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
|
|
6865
6925
|
// silently swallowed and flip the command into live-publish mode.
|
|
6866
|
-
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json']);
|
|
6867
|
-
const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key']);
|
|
6868
|
-
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --no-guard "<reason>" (skip the guard pre-flight; logged)';
|
|
6926
|
+
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
|
|
6927
|
+
const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
|
|
6928
|
+
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged)';
|
|
6869
6929
|
for (const flag of flags) {
|
|
6870
6930
|
if (!allowedFlags.has(flag)) {
|
|
6871
6931
|
write(`dz publish: unknown option --${flag}`);
|
|
@@ -7045,7 +7105,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7045
7105
|
// longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
|
|
7046
7106
|
// new flag; `--sign-key` overrides it.
|
|
7047
7107
|
const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
|
|
7048
|
-
const
|
|
7108
|
+
const publishReport = publishPackages(cwd, {
|
|
7049
7109
|
provenance,
|
|
7050
7110
|
dryRun,
|
|
7051
7111
|
filter,
|
|
@@ -7125,9 +7185,82 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7125
7185
|
},
|
|
7126
7186
|
});
|
|
7127
7187
|
|
|
7188
|
+
const configMirror = mirrorCommandFromConfig(cwd);
|
|
7189
|
+
const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
|
|
7190
|
+
const publishedVersions = publishReport.packages
|
|
7191
|
+
.filter((pkg) => pkg.status === 'published')
|
|
7192
|
+
.map((pkg) => `${pkg.name}@${pkg.newVersion}`);
|
|
7193
|
+
const expected = publishedVersions.join(',');
|
|
7194
|
+
const fullMirrorCommand = configuredCommand === ''
|
|
7195
|
+
? ''
|
|
7196
|
+
: `${configuredCommand} --expect ${mirrorShellToken(expected)} --json`;
|
|
7197
|
+
let mirror: PublishMirrorState;
|
|
7198
|
+
|
|
7199
|
+
// This exact conjunction is the Step-7/8 mutation anchor: an epilogue is eligible only after a
|
|
7200
|
+
// live sweep that actually landed at least one package. Other explicit skip states are handled
|
|
7201
|
+
// before command resolution so each reason remains distinguishable in text and JSON.
|
|
7202
|
+
const mirrorEligible = !dryRun && publishReport.published >= 1;
|
|
7203
|
+
if (bumpOnly) {
|
|
7204
|
+
mirror = { status: 'skipped', command: fullMirrorCommand, reason: 'bump-only' };
|
|
7205
|
+
} else if (!mirrorEligible) {
|
|
7206
|
+
mirror = {
|
|
7207
|
+
status: 'skipped',
|
|
7208
|
+
command: fullMirrorCommand,
|
|
7209
|
+
reason: dryRun ? 'dry-run' : 'published=0',
|
|
7210
|
+
};
|
|
7211
|
+
} else if (flags.has('no-mirror')) {
|
|
7212
|
+
mirror = { status: 'skipped', command: fullMirrorCommand, reason: '--no-mirror' };
|
|
7213
|
+
} else if (configMirror.warning !== undefined && options.get('mirror-cmd') === undefined) {
|
|
7214
|
+
mirror = { status: 'not-configured', command: '', warning: configMirror.warning };
|
|
7215
|
+
} else if (configuredCommand === '') {
|
|
7216
|
+
mirror = { status: 'not-configured', command: '' };
|
|
7217
|
+
} else {
|
|
7218
|
+
const runMirror: PublishMirrorRunner = mirrorRunner
|
|
7219
|
+
?? ((command, runnerOptions) => execSync(command, {
|
|
7220
|
+
cwd: runnerOptions.cwd,
|
|
7221
|
+
env: runnerOptions.env,
|
|
7222
|
+
encoding: 'utf8',
|
|
7223
|
+
stdio: 'pipe',
|
|
7224
|
+
}));
|
|
7225
|
+
try {
|
|
7226
|
+
const stdout = runMirror(fullMirrorCommand, {
|
|
7227
|
+
cwd,
|
|
7228
|
+
env: { ...process.env, DZ_PUBLISHED: expected },
|
|
7229
|
+
});
|
|
7230
|
+
const parsed = JSON.parse(stdout) as {
|
|
7231
|
+
ok?: unknown;
|
|
7232
|
+
commit?: unknown;
|
|
7233
|
+
receipt?: { manifestUrl?: unknown; confirmedAt?: unknown; waitedMs?: unknown };
|
|
7234
|
+
error?: unknown;
|
|
7235
|
+
};
|
|
7236
|
+
if (parsed.ok !== true) throw new Error(typeof parsed.error === 'string' ? parsed.error : 'mirror command returned ok:false');
|
|
7237
|
+
if (typeof parsed.commit !== 'string' || parsed.commit === '') throw new Error('mirror command returned no commit');
|
|
7238
|
+
if (typeof parsed.receipt?.manifestUrl !== 'string'
|
|
7239
|
+
|| typeof parsed.receipt.confirmedAt !== 'string'
|
|
7240
|
+
|| typeof parsed.receipt.waitedMs !== 'number') {
|
|
7241
|
+
throw new Error('mirror command returned no live-manifest receipt');
|
|
7242
|
+
}
|
|
7243
|
+
mirror = {
|
|
7244
|
+
status: 'confirmed',
|
|
7245
|
+
command: fullMirrorCommand,
|
|
7246
|
+
commit: parsed.commit,
|
|
7247
|
+
receipt: {
|
|
7248
|
+
manifestUrl: parsed.receipt.manifestUrl,
|
|
7249
|
+
confirmedAt: parsed.receipt.confirmedAt,
|
|
7250
|
+
waitedMs: parsed.receipt.waitedMs,
|
|
7251
|
+
},
|
|
7252
|
+
};
|
|
7253
|
+
} catch (error) {
|
|
7254
|
+
mirror = { status: 'unconfirmed', command: fullMirrorCommand, error: mirrorFailureMessage(error) };
|
|
7255
|
+
}
|
|
7256
|
+
}
|
|
7257
|
+
|
|
7258
|
+
const report = { ...publishReport, mirror };
|
|
7259
|
+
const exitCode = report.errors > 0 ? 1 : mirror.status === 'unconfirmed' ? 3 : 0;
|
|
7260
|
+
|
|
7128
7261
|
if (json) {
|
|
7129
7262
|
writeOutput(JSON.stringify(report));
|
|
7130
|
-
return
|
|
7263
|
+
return exitCode;
|
|
7131
7264
|
}
|
|
7132
7265
|
|
|
7133
7266
|
write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
|
|
@@ -7177,7 +7310,21 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7177
7310
|
for (const item of pkg.notVerified) write(` · ${item}`);
|
|
7178
7311
|
}
|
|
7179
7312
|
}
|
|
7180
|
-
|
|
7313
|
+
for (const warning of report.warnings ?? []) write(` ⚠ warning: ${warning}`);
|
|
7314
|
+
for (const path of report.releaseLineSynced ?? []) write(` ↳ release line synced: ${path}`);
|
|
7315
|
+
if (mirror.status === 'confirmed') {
|
|
7316
|
+
write(` ✓ mirror: confirmed — ${mirror.commit} (${mirror.receipt?.manifestUrl})`);
|
|
7317
|
+
} else if (mirror.status === 'not-configured') {
|
|
7318
|
+
if (mirror.warning !== undefined) write(` ⚠ mirror: ${mirror.warning}`);
|
|
7319
|
+
else write(' ℹ mirror: not configured');
|
|
7320
|
+
} else if (mirror.status === 'skipped') {
|
|
7321
|
+
write(` ℹ mirror: skipped (${mirror.reason})`);
|
|
7322
|
+
} else {
|
|
7323
|
+
write(` ✗ mirror: unconfirmed — ${mirror.error}`);
|
|
7324
|
+
const rerun = mirror.command || 'configure publish.mirrorCommand, then run it';
|
|
7325
|
+
write(`dz publish: published, mirror NOT confirmed — ${mirror.error}; re-run: ${rerun}`);
|
|
7326
|
+
}
|
|
7327
|
+
return exitCode;
|
|
7181
7328
|
}
|
|
7182
7329
|
|
|
7183
7330
|
/* ------------------------------------------------------------------ */
|
|
@@ -9635,21 +9782,52 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9635
9782
|
// a resolvable workspace dep becomes its real semver (safe → the rule passes); an UNRESOLVABLE one (points at
|
|
9636
9783
|
// no workspace package, or not a pnpm workspace) stays `workspace:*` so the rule catches a dep that WOULD ship
|
|
9637
9784
|
// raw. That is the genuinely dangerous case the rule exists for.
|
|
9638
|
-
type Manifest = { name?: string; version?: string; private?: boolean; license?: string; licenseHold?: unknown; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
9785
|
+
type Manifest = { name?: string; version?: string; private?: boolean; license?: string; licenseHold?: unknown; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; optionalDependencies?: Record<string, string> };
|
|
9639
9786
|
const manifests: Manifest[] = [];
|
|
9640
|
-
const located: { dir: string; m: Manifest }[] = [];
|
|
9787
|
+
const located: { dir: string; m: Manifest; text: string }[] = [];
|
|
9641
9788
|
try {
|
|
9642
9789
|
const out = volumeGitText(root, ['ls-files', 'packages/@dzhechkov/*/package.json']);
|
|
9643
9790
|
for (const rel of out.split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
9644
9791
|
try {
|
|
9645
|
-
const
|
|
9792
|
+
const packageJsonText = readFileSync(join(root, rel), 'utf8');
|
|
9793
|
+
const m = JSON.parse(packageJsonText) as Manifest;
|
|
9646
9794
|
manifests.push(m);
|
|
9647
|
-
located.push({ dir: rel.replace(/\/package\.json$/, ''), m });
|
|
9795
|
+
located.push({ dir: rel.replace(/\/package\.json$/, ''), m, text: packageJsonText });
|
|
9648
9796
|
} catch { /* skip unreadable */ }
|
|
9649
9797
|
}
|
|
9650
9798
|
} catch { /* not a git repo */ }
|
|
9651
9799
|
const versionByName = new Map<string, string>();
|
|
9652
9800
|
for (const m of manifests) if (m.name && typeof m.version === 'string') versionByName.set(m.name, m.version);
|
|
9801
|
+
// release-line-in-sync: filesystem access belongs in the CLI gatherer. The core evaluator receives
|
|
9802
|
+
// only immutable evidence, so evaluateGuard(facts) stays deterministic and independent of cwd.
|
|
9803
|
+
const coreManifestPath = join(root, 'packages', '@dzhechkov', 'harness-core', 'package.json');
|
|
9804
|
+
const cliManifestPath = join(root, 'packages', '@dzhechkov', 'harness-cli', 'package.json');
|
|
9805
|
+
if (existsSync(coreManifestPath) || existsSync(cliManifestPath)) {
|
|
9806
|
+
const readText = (path: string): string | null => {
|
|
9807
|
+
try { return readFileSync(path, 'utf8'); } catch { return null; }
|
|
9808
|
+
};
|
|
9809
|
+
const readVersion = (path: string): string | null => {
|
|
9810
|
+
const raw = readText(path);
|
|
9811
|
+
if (raw === null) return null;
|
|
9812
|
+
try {
|
|
9813
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
9814
|
+
return typeof parsed.version === 'string' && /^\d+\.\d+\.\d+$/.test(parsed.version)
|
|
9815
|
+
? parsed.version
|
|
9816
|
+
: null;
|
|
9817
|
+
} catch { return null; }
|
|
9818
|
+
};
|
|
9819
|
+
facts['releaseLines'] = {
|
|
9820
|
+
coreVersion: readVersion(coreManifestPath),
|
|
9821
|
+
cliVersion: readVersion(cliManifestPath),
|
|
9822
|
+
readmes: [
|
|
9823
|
+
{ path: 'README.md', text: readText(join(root, 'README.md')) },
|
|
9824
|
+
{
|
|
9825
|
+
path: 'packages/@dzhechkov/harness-cli/README.md',
|
|
9826
|
+
text: readText(join(root, 'packages', '@dzhechkov', 'harness-cli', 'README.md')),
|
|
9827
|
+
},
|
|
9828
|
+
],
|
|
9829
|
+
};
|
|
9830
|
+
}
|
|
9653
9831
|
publishPackageRoots.push(...located
|
|
9654
9832
|
.filter(({ dir, m }) => m.private !== true && (
|
|
9655
9833
|
publishFilter === undefined
|
|
@@ -9811,22 +9989,136 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9811
9989
|
}
|
|
9812
9990
|
} catch { /* нечитаемо — правило молчит */ }
|
|
9813
9991
|
facts['counts'] = gatherReadmeCounts(root);
|
|
9814
|
-
//
|
|
9815
|
-
//
|
|
9992
|
+
// One package-scoped WORKING-TREE diff feeds both readme-first and signature-fresh. A second
|
|
9993
|
+
// porcelain call could observe a different tree and let the two publish guards disagree.
|
|
9994
|
+
let changedPackageFiles: string[] | undefined;
|
|
9995
|
+
// readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: did
|
|
9996
|
+
// the version FIELD change from HEAD without its README.md changing? A package.json edit alone
|
|
9997
|
+
// says nothing about version; repository/metadata-only edits must not manufacture a bump.
|
|
9816
9998
|
try {
|
|
9817
|
-
const status = execSync('git status --porcelain -- "packages/@dzhechkov/"', { cwd: root, encoding: 'utf-8' });
|
|
9818
|
-
|
|
9819
|
-
|
|
9820
|
-
|
|
9999
|
+
const status = execSync('git status --porcelain -uall -- "packages/@dzhechkov/"', { cwd: root, encoding: 'utf-8' });
|
|
10000
|
+
changedPackageFiles = status
|
|
10001
|
+
.split('\n')
|
|
10002
|
+
.map((line) => line.slice(3).trim())
|
|
10003
|
+
.map((path) => (path.includes(' -> ') ? path.split(' -> ')[1]!.trim() : path))
|
|
10004
|
+
.filter(Boolean);
|
|
10005
|
+
const perPack = new Map<string, { readme: boolean }>();
|
|
10006
|
+
for (const rel of changedPackageFiles) {
|
|
10007
|
+
const m = rel.match(/^packages\/@dzhechkov\/([^/]+)\/(.+)$/);
|
|
9821
10008
|
if (!m || !m[1] || !m[2]) continue;
|
|
9822
|
-
const e = perPack.get(m[1]) ?? {
|
|
9823
|
-
if (m[2] === 'package.json') e.pkgJson = true;
|
|
10009
|
+
const e = perPack.get(m[1]) ?? { readme: false };
|
|
9824
10010
|
if (m[2] === 'README.md') e.readme = true;
|
|
9825
10011
|
perPack.set(m[1], e);
|
|
9826
10012
|
}
|
|
9827
|
-
|
|
10013
|
+
const readVersion = (raw: string): string => {
|
|
10014
|
+
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
|
10015
|
+
if (typeof version !== 'string') throw new Error('package version is unreadable');
|
|
10016
|
+
return version;
|
|
10017
|
+
};
|
|
10018
|
+
facts['readmeFirst'] = [...perPack.entries()].map(([name, e]) => {
|
|
10019
|
+
const packagePath = `packages/@dzhechkov/${name}/package.json`;
|
|
10020
|
+
let versionBumped = false;
|
|
10021
|
+
let versionUnknown = false;
|
|
10022
|
+
try {
|
|
10023
|
+
const treeVersion = readVersion(readFileSync(join(root, packagePath), 'utf8'));
|
|
10024
|
+
try {
|
|
10025
|
+
const headVersion = readVersion(execFileSync('git', ['show', `HEAD:${packagePath}`], {
|
|
10026
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
10027
|
+
}));
|
|
10028
|
+
versionBumped = treeVersion !== headVersion;
|
|
10029
|
+
} catch {
|
|
10030
|
+
try {
|
|
10031
|
+
const trackedAtHead = execFileSync('git', ['ls-tree', '--name-only', 'HEAD', '--', packagePath], {
|
|
10032
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
10033
|
+
}).trim() === packagePath;
|
|
10034
|
+
if (trackedAtHead) versionUnknown = true;
|
|
10035
|
+
else versionBumped = true;
|
|
10036
|
+
} catch {
|
|
10037
|
+
versionUnknown = true;
|
|
10038
|
+
}
|
|
10039
|
+
}
|
|
10040
|
+
} catch {
|
|
10041
|
+
versionUnknown = true;
|
|
10042
|
+
}
|
|
10043
|
+
return {
|
|
10044
|
+
name: '@dzhechkov/' + name,
|
|
10045
|
+
versionBumped,
|
|
10046
|
+
readmeChanged: e.readme,
|
|
10047
|
+
...(versionUnknown ? { versionUnknown: true } : {}),
|
|
10048
|
+
};
|
|
10049
|
+
});
|
|
9828
10050
|
} catch { /* not a git repo — rule skips */ }
|
|
9829
10051
|
|
|
10052
|
+
// signature-fresh: verify only signed packs whose non-signature files changed in the same
|
|
10053
|
+
// porcelain listing above. Manifest/SBOM churn is the remedy, not another reason to verify.
|
|
10054
|
+
// The trust key and every byte read here stay in the CLI; the guard core receives facts only.
|
|
10055
|
+
const trustRootPath = join(root, TRUST_ROOT_REL);
|
|
10056
|
+
if (changedPackageFiles !== undefined && existsSync(trustRootPath)) {
|
|
10057
|
+
try {
|
|
10058
|
+
const trustRootPem = readFileSync(trustRootPath, 'utf8');
|
|
10059
|
+
const signedPacks: {
|
|
10060
|
+
name: string;
|
|
10061
|
+
dir: string;
|
|
10062
|
+
changed: boolean;
|
|
10063
|
+
ok: boolean | null;
|
|
10064
|
+
failures: string[];
|
|
10065
|
+
note?: string;
|
|
10066
|
+
}[] = [];
|
|
10067
|
+
for (const { dir, m, text: packageJsonText } of located) {
|
|
10068
|
+
const manifestPath = join(root, dir, MANIFEST_NAME);
|
|
10069
|
+
if (!existsSync(manifestPath)) continue;
|
|
10070
|
+
const prefix = dir + '/';
|
|
10071
|
+
const changedPackFiles = new Set(changedPackageFiles
|
|
10072
|
+
.filter((rel) => rel.startsWith(prefix))
|
|
10073
|
+
.map((rel) => rel.slice(prefix.length))
|
|
10074
|
+
.filter((packRel) => packRel !== MANIFEST_NAME && packRel !== SBOM_NAME));
|
|
10075
|
+
const changed = changedPackFiles.size > 0;
|
|
10076
|
+
let ok: boolean | null = null;
|
|
10077
|
+
let failures: string[] = [];
|
|
10078
|
+
let note: string | undefined;
|
|
10079
|
+
if (changed) {
|
|
10080
|
+
try {
|
|
10081
|
+
const signed = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
|
10082
|
+
manifest?: { files?: readonly { path?: unknown; sha256?: unknown }[] };
|
|
10083
|
+
};
|
|
10084
|
+
const verified = verifyManifest(join(root, dir), signed as never, trustRootPem);
|
|
10085
|
+
// A source checkout contains dev files outside package.json#files. They are absent
|
|
10086
|
+
// from the tarball by design, so an unchanged "present but not signed" source file
|
|
10087
|
+
// is not evidence about signature freshness. A changed/new unsigned file stays loud.
|
|
10088
|
+
const relevantFailures = verified.failures.filter((failure) =>
|
|
10089
|
+
failure.reason !== 'present in the pack but not signed' || changedPackFiles.has(failure.path));
|
|
10090
|
+
ok = verified.ok || relevantFailures.length === 0;
|
|
10091
|
+
if (!ok && relevantFailures.length > 0 && relevantFailures.every(({ path }) => path === 'package.json')) {
|
|
10092
|
+
const signedPackageHash = signed.manifest?.files?.find(({ path }) => path === 'package.json')?.sha256;
|
|
10093
|
+
if (typeof signedPackageHash === 'string') {
|
|
10094
|
+
const rewritten = rewriteWorkspaceSpecs(packageJsonText, versionByName);
|
|
10095
|
+
// MEASURED 2026-09-10 on keysarium/skills-meta/core: pnpm also removes only
|
|
10096
|
+
// prepublishOnly from the packed scripts table. Keep that I/O-adapter concern
|
|
10097
|
+
// outside the workspace-only core helper.
|
|
10098
|
+
const packedShape = JSON.parse(rewritten) as Record<string, unknown>;
|
|
10099
|
+
const scripts = packedShape['scripts'];
|
|
10100
|
+
if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts)) {
|
|
10101
|
+
delete (scripts as Record<string, unknown>)['prepublishOnly'];
|
|
10102
|
+
}
|
|
10103
|
+
const rewrittenHash = hashPackBytes('package.json', Buffer.from(JSON.stringify(packedShape, null, 2) + '\n'), (signed.manifest as { version?: number } | undefined)?.version);
|
|
10104
|
+
if (rewrittenHash === signedPackageHash) {
|
|
10105
|
+
ok = true;
|
|
10106
|
+
note = 'package.json matches after workspace rewrite';
|
|
10107
|
+
}
|
|
10108
|
+
}
|
|
10109
|
+
}
|
|
10110
|
+
failures = relevantFailures.slice(0, 3).map((failure) => `${failure.path}: ${failure.reason}`);
|
|
10111
|
+
} catch (error) {
|
|
10112
|
+
ok = false;
|
|
10113
|
+
failures = [`${MANIFEST_NAME}: ${error instanceof Error ? error.message : String(error)}`];
|
|
10114
|
+
}
|
|
10115
|
+
}
|
|
10116
|
+
signedPacks.push({ name: m.name ?? dir, dir, changed, ok, failures, ...(note !== undefined ? { note } : {}) });
|
|
10117
|
+
}
|
|
10118
|
+
facts['signedPacks'] = signedPacks;
|
|
10119
|
+
} catch { /* unreadable trust root — absence of evidence, so the rule is not established */ }
|
|
10120
|
+
}
|
|
10121
|
+
|
|
9830
10122
|
// review-round: the same WORKING-TREE diff, asked a different question — does a package that
|
|
9831
10123
|
// bumps its version and changes SOURCE bring a GRADED QE report with it? Scoped to source so a
|
|
9832
10124
|
// docs-only republish is never blocked (ADR-001, features/publish-needs-a-review). A throw here
|
|
@@ -17958,7 +18250,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
17958
18250
|
// `dz recap` — a refusal built on either list would reject working commands, which is a worse
|
|
17959
18251
|
// failure than the one being fixed. Goes to STDERR so a `--json` consumer's stdout stays clean.
|
|
17960
18252
|
if (command !== 'contract-check') {
|
|
17961
|
-
for (const notice of unknownFlagNotice(
|
|
18253
|
+
for (const notice of unknownFlagNotice(
|
|
18254
|
+
[...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
|
|
18255
|
+
KNOWN_CLI_FLAGS,
|
|
18256
|
+
)) {
|
|
17962
18257
|
writeErr(notice.line);
|
|
17963
18258
|
}
|
|
17964
18259
|
}
|
|
@@ -18115,7 +18410,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
18115
18410
|
case 'auto-canonicalize':
|
|
18116
18411
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
18117
18412
|
case 'publish':
|
|
18118
|
-
return cmdPublish(options, flags, cwd, write);
|
|
18413
|
+
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
|
|
18119
18414
|
case 'release':
|
|
18120
18415
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
18121
18416
|
case 'parity':
|