@dk/hipp 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +87 -0
  2. package/hipp.js +357 -0
  3. package/package.json +23 -0
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # ๐Ÿš€ HIPP: High Integrity Package Publisher
2
+
3
+ By Dmytri Kleiner <dev@dmytri.to>
4
+
5
+ **HIPP** is a minimalist, stateless publishing tool designed to eliminate the
6
+ friction of version-bump commits. It treats your **Git Tags** as the single
7
+ source of truth, enforcing a "Ground State" where your `package.json` version
8
+ remains permanently at `0.0.0`.
9
+
10
+ ---
11
+
12
+ ## ๐Ÿง Why HIPP?
13
+
14
+ Traditional NPM versioning requires you to store your "Version of Truth" inside
15
+ your source code files (`package.json`). This creates a **State Conflict** that
16
+ leads to several systemic problems:
17
+
18
+ ### 1. Integrity Failure in standard workflow
19
+ `npm version` and `git tag` are two distinct, non-atomic actions. If you tag a
20
+ commit but forget to update the JSON (or vice-versa), your registry package and
21
+ your Git history diverge. This scenario makes it impossible to guarantee that
22
+ the code in the registry matches the code at that tag.
23
+
24
+ **HIPP ensures they are fundamentally linked by extracting the version directly
25
+ from the Git Tag.**
26
+
27
+ ### 2. Chore" Noise & Merge Conflicts
28
+ Every release usually requires a "chore: bump version" commit. When multiple
29
+ branches are developed simultaneously, these version changes cause constant,
30
+ trivial merge conflicts.
31
+
32
+ **HIPP makes your `package.json` version immutable (0.0.0), so it never
33
+ conflicts and your git history stays clean.**
34
+
35
+ ---
36
+
37
+ ## ๐Ÿ›  Usage
38
+
39
+ ### 1. The Setup Set your project's `package.json` version to `0.0.0`.
40
+ This is the **HIPP Doctrine**.
41
+
42
+ ```json
43
+ { "name": "your-package", "version": "0.0.0" }
44
+ ```
45
+
46
+ ### 2. Tag and Publish with HIPP
47
+
48
+ ```bash
49
+ git tag v1.0.0
50
+ npx @dk/hipp
51
+ ```
52
+
53
+ HIPP will:
54
+ 1. **Verify**: Ensure the `0.0.0` doctrine is being followed.
55
+ 2. **Clean Check**: Ensure your git status is clean (no uncommitted local
56
+ "drift").
57
+ 3. **Validate**: Extract and verify the latest tag against Semver rules.
58
+ 4. **Confirm**: Ask for a ๐Ÿš€ confirmation before ignition.
59
+ 5. **Restore**: Automatically return your local files to `0.0.0` after the
60
+ smoke clears.
61
+
62
+ ### Options
63
+ * `-y, --yes`: Skip the confirmation (ideal for CI/CD pipelines).
64
+
65
+ If you need to pass additional flags to npm publish (like access or a custom registry), use the -- separator:
66
+ Bash
67
+
68
+ `npx @dk/hipp -- --access public --tag beta`
69
+
70
+
71
+ ---
72
+
73
+ ## โš–๏ธ License
74
+
75
+ **0BSD** (BSD Zero Clause License) By Dmytri Kleiner <dev@dmytri.to>
76
+
77
+ Permission to use, copy, modify, and/or distribute this software for any
78
+ purpose with or without fee is hereby granted.
79
+
80
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
81
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
82
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
83
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
84
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
85
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
86
+ PERFORMANCE OF THIS SOFTWARE. ```
87
+
package/hipp.js ADDED
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+ const { spawnSync, execFileSync } = require('child_process');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const semver = require('semver');
6
+ const readline = require('readline');
7
+ const os = require('os');
8
+ const crypto = require('crypto');
9
+
10
+ const log = {
11
+ error: (msg) => console.error(`\x1b[31m${msg}\x1b[0m`),
12
+ info: (msg) => console.log(`\x1b[36m${msg}\x1b[0m`),
13
+ success: (msg) => console.log(`\x1b[32m${msg}\x1b[0m`),
14
+ warn: (msg) => console.warn(`\x1b[33m${msg}\x1b[0m`),
15
+ };
16
+
17
+ function fail(msg) {
18
+ log.error(msg);
19
+ process.exit(1);
20
+ }
21
+
22
+ function git(args, options = {}) {
23
+ return execFileSync('git', args, {
24
+ encoding: 'utf8',
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ ...options,
27
+ }).trim();
28
+ }
29
+
30
+ function runCmd(cmd, args, options = {}) {
31
+ const result = spawnSync(cmd, args, {
32
+ encoding: 'utf8',
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ ...options,
35
+ });
36
+
37
+ if (result.error) throw result.error;
38
+ return result;
39
+ }
40
+
41
+ function sha256(input) {
42
+ return crypto.createHash('sha256').update(input).digest('hex');
43
+ }
44
+
45
+ function safeStageName(name) {
46
+ return name.replace(/[^a-zA-Z0-9._-]/g, '-');
47
+ }
48
+
49
+ function getVersionFromExactTagOnHead() {
50
+ try {
51
+ const rawTag = git(['describe', '--tags', '--exact-match', 'HEAD']);
52
+ if (!rawTag.startsWith('v')) {
53
+ throw new Error(`tag "${rawTag}" must start with "v"`);
54
+ }
55
+ const clean = semver.clean(rawTag);
56
+ if (!clean) {
57
+ throw new Error(`tag "${rawTag}" is not valid semver`);
58
+ }
59
+ return { rawTag, version: clean };
60
+ } catch (err) {
61
+ fail(`โŒ Integrity Error: HEAD must have an exact v-prefixed semver tag. ${err.message}`);
62
+ }
63
+ }
64
+
65
+ function ensureCleanRepo(pkg) {
66
+ if (pkg.version !== '0.0.0') {
67
+ fail('โŒ Integrity Violation: package.json version must be 0.0.0');
68
+ }
69
+
70
+ if (pkg.workspaces) {
71
+ fail('โŒ Workspace Error: HIPP currently only supports single-package repositories.');
72
+ }
73
+
74
+ const status = git(['status', '--porcelain']);
75
+ if (status) {
76
+ fail('โŒ Integrity Error: Uncommitted changes found.');
77
+ }
78
+ }
79
+
80
+ function ensureMutableRefPolicy() {
81
+ let branch;
82
+ try {
83
+ branch = git(['symbolic-ref', '--short', 'HEAD']);
84
+ } catch {
85
+ fail('โŒ Ref Error: Detached HEAD is not allowed for publish.');
86
+ }
87
+
88
+ let upstream;
89
+ try {
90
+ upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
91
+ } catch {
92
+ fail(`โŒ Ref Error: Branch "${branch}" must track an upstream branch.`);
93
+ }
94
+
95
+ const head = git(['rev-parse', 'HEAD']);
96
+ const upstreamHead = git(['rev-parse', '@{u}']);
97
+
98
+ if (head !== upstreamHead) {
99
+ fail(`โŒ Ref Error: HEAD (${head.slice(0, 12)}) must exactly match upstream (${upstream} ${upstreamHead.slice(0, 12)}).`);
100
+ }
101
+
102
+ return { branch, upstream, head };
103
+ }
104
+
105
+ function ensureRemoteProvenance(rawTag, headSha) {
106
+ let remoteUrl;
107
+ try {
108
+ remoteUrl = git(['remote', 'get-url', 'origin']);
109
+ } catch {
110
+ fail('โŒ Provenance Error: Remote "origin" is required.');
111
+ }
112
+
113
+ const tagObjectLocal = git(['rev-parse', rawTag]);
114
+ const tagCommitLocal = git(['rev-list', '-n', '1', rawTag]);
115
+
116
+ const remoteTagObject = git(['ls-remote', '--tags', 'origin', `refs/tags/${rawTag}`])
117
+ .split('\t')[0]
118
+ .trim();
119
+
120
+ const remoteTagCommit = git(['ls-remote', '--tags', 'origin', `refs/tags/${rawTag}^{}`])
121
+ .split('\t')[0]
122
+ .trim();
123
+
124
+ if (!remoteTagObject) {
125
+ fail(`โŒ Provenance Error: Tag "${rawTag}" does not exist on origin (${remoteUrl}).`);
126
+ }
127
+
128
+ if (remoteTagObject !== tagObjectLocal) {
129
+ fail(`โŒ Provenance Error: Local tag object for "${rawTag}" does not match origin.`);
130
+ }
131
+
132
+ if (remoteTagCommit && remoteTagCommit !== tagCommitLocal) {
133
+ fail(`โŒ Provenance Error: Local tag target commit for "${rawTag}" does not match origin.`);
134
+ }
135
+
136
+ const remoteContains = runCmd('git', ['branch', '-r', '--contains', headSha], {
137
+ encoding: 'utf8',
138
+ });
139
+
140
+ if (remoteContains.status !== 0) {
141
+ fail('โŒ Provenance Error: Could not verify remote containment for HEAD.');
142
+ }
143
+
144
+ const remoteBranches = remoteContains.stdout
145
+ .split('\n')
146
+ .map((s) => s.trim().replace(/^\* /, ''))
147
+ .filter(Boolean);
148
+
149
+ const onOrigin = remoteBranches.some((b) => b.startsWith('origin/'));
150
+ if (!onOrigin) {
151
+ fail('โŒ Provenance Error: HEAD commit is not contained in any origin remote branch.');
152
+ }
153
+
154
+ return { remoteUrl };
155
+ }
156
+
157
+ function ensureLockIntegrity(pkg) {
158
+ const lockPath = path.join(process.cwd(), 'package-lock.json');
159
+ if (!fs.existsSync(lockPath)) {
160
+ fail('โŒ Lock Error: package-lock.json is required.');
161
+ }
162
+
163
+ try {
164
+ git(['ls-files', '--error-unmatch', 'package-lock.json']);
165
+ } catch {
166
+ fail('โŒ Lock Error: package-lock.json must be tracked by git.');
167
+ }
168
+
169
+ const pkgJsonRaw = fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8');
170
+ const lockJsonRaw = fs.readFileSync(lockPath, 'utf8');
171
+
172
+ let lock;
173
+ try {
174
+ lock = JSON.parse(lockJsonRaw);
175
+ } catch {
176
+ fail('โŒ Lock Error: package-lock.json is not valid JSON.');
177
+ }
178
+
179
+ if (!lock.name || !lock.version) {
180
+ fail('โŒ Lock Error: package-lock.json is missing top-level name/version.');
181
+ }
182
+
183
+ if (lock.name !== pkg.name) {
184
+ fail(`โŒ Lock Error: package-lock.json name mismatch. Expected ${pkg.name}, got ${lock.name}.`);
185
+ }
186
+
187
+ if (lock.version !== pkg.version) {
188
+ fail(`โŒ Lock Error: package-lock.json version mismatch. Expected ${pkg.version}, got ${lock.version}.`);
189
+ }
190
+
191
+ const ciCheck = runCmd('npm', ['ci', '--ignore-scripts', '--dry-run'], {
192
+ cwd: process.cwd(),
193
+ env: { ...process.env, npm_config_fund: 'false', npm_config_audit: 'false' },
194
+ });
195
+
196
+ if (ciCheck.status !== 0) {
197
+ process.stderr.write(ciCheck.stderr || '');
198
+ fail('โŒ Lock Error: `npm ci --ignore-scripts --dry-run` failed.');
199
+ }
200
+
201
+ return {
202
+ lockfileSha256: sha256(lockJsonRaw),
203
+ packageJsonSha256: sha256(pkgJsonRaw),
204
+ };
205
+ }
206
+
207
+ function getTrackedFiles() {
208
+ const out = execFileSync('git', ['ls-files', '-z'], {
209
+ encoding: 'buffer',
210
+ stdio: ['pipe', 'pipe', 'pipe'],
211
+ });
212
+
213
+ return out
214
+ .toString('utf8')
215
+ .split('\0')
216
+ .filter(Boolean);
217
+ }
218
+
219
+ function copyTrackedFiles(stageDir, files) {
220
+ const repoRoot = process.cwd();
221
+
222
+ for (const rel of files) {
223
+ const src = path.join(repoRoot, rel);
224
+ const dest = path.join(stageDir, rel);
225
+ const stat = fs.lstatSync(src);
226
+
227
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
228
+
229
+ if (stat.isSymbolicLink()) {
230
+ const target = fs.readlinkSync(src);
231
+ fs.symlinkSync(target, dest);
232
+ } else if (stat.isDirectory()) {
233
+ fs.mkdirSync(dest, { recursive: true });
234
+ } else if (stat.isFile()) {
235
+ fs.copyFileSync(src, dest);
236
+ }
237
+ }
238
+ }
239
+
240
+ async function confirmPrompt(name, version) {
241
+ const rl = readline.createInterface({
242
+ input: process.stdin,
243
+ output: process.stdout,
244
+ });
245
+
246
+ try {
247
+ const answer = await new Promise((resolve) => {
248
+ rl.question(`๐Ÿš€ Confirm launch of ${name}@${version}? [y/N] `, resolve);
249
+ });
250
+ return /^(y|yes)$/i.test(answer.trim());
251
+ } finally {
252
+ rl.close();
253
+ }
254
+ }
255
+
256
+ async function run() {
257
+ const args = process.argv.slice(2);
258
+ const sep = args.indexOf('--');
259
+ const hippArgs = sep !== -1 ? args.slice(0, sep) : args;
260
+ const npmArgs = sep !== -1 ? args.slice(sep + 1) : [];
261
+ const skipPrompt = hippArgs.includes('--yes') || hippArgs.includes('-y');
262
+
263
+ const pkgPath = path.resolve(process.cwd(), 'package.json');
264
+ if (!fs.existsSync(pkgPath)) {
265
+ fail('โŒ Error: No package.json found.');
266
+ }
267
+
268
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
269
+ ensureCleanRepo(pkg);
270
+
271
+ const { rawTag, version } = getVersionFromExactTagOnHead();
272
+ const refInfo = ensureMutableRefPolicy();
273
+ const provenance = ensureRemoteProvenance(rawTag, refInfo.head);
274
+ const lockInfo = ensureLockIntegrity(pkg);
275
+ const trackedFiles = getTrackedFiles();
276
+
277
+ log.info('๐Ÿš€ HIPP: High Integrity Package Publisher');
278
+ log.success(`๐Ÿท๏ธ Git Tag Truth: ${rawTag}`);
279
+ log.success(`๐ŸŒฟ Ref Truth: ${refInfo.branch} == ${refInfo.upstream}`);
280
+ log.success(`๐ŸŒ Origin Truth: ${provenance.remoteUrl}`);
281
+ log.success(`๐Ÿ”’ Lock Truth: ${lockInfo.lockfileSha256.slice(0, 12)}โ€ฆ`);
282
+
283
+ if (!skipPrompt) {
284
+ const confirmed = await confirmPrompt(pkg.name, version);
285
+ if (!confirmed) {
286
+ log.warn('Aborted.');
287
+ process.exit(0);
288
+ }
289
+ }
290
+
291
+ const stageDir = fs.mkdtempSync(
292
+ path.join(os.tmpdir(), `hipp-${safeStageName(pkg.name)}-`)
293
+ );
294
+
295
+ try {
296
+ log.info(`๐Ÿ—๏ธ Staging tracked files to ${stageDir}...`);
297
+ copyTrackedFiles(stageDir, trackedFiles);
298
+
299
+ const stagedPkgPath = path.join(stageDir, 'package.json');
300
+ const stagedPkg = JSON.parse(fs.readFileSync(stagedPkgPath, 'utf8'));
301
+ stagedPkg.version = version;
302
+ fs.writeFileSync(stagedPkgPath, JSON.stringify(stagedPkg, null, 2) + '\n');
303
+
304
+ log.info('๐Ÿ”ฅ Ignition...');
305
+
306
+ const result = spawnSync('npm', ['publish', ...npmArgs], {
307
+ cwd: stageDir,
308
+ stdio: 'inherit',
309
+ env: {
310
+ ...process.env,
311
+ npm_config_fund: 'false',
312
+ npm_config_audit: 'false',
313
+ },
314
+ });
315
+
316
+ if (result.error) {
317
+ throw result.error;
318
+ }
319
+
320
+ if (result.status !== 0) {
321
+ throw new Error(`npm publish exited with code ${result.status}`);
322
+ }
323
+
324
+ log.success(`\nโœจ Success! Published ${pkg.name}@${version}`);
325
+ } catch (err) {
326
+ fail(`\n๐Ÿ’ฅ Launch failed: ${err.message}`);
327
+ } finally {
328
+ fs.rmSync(stageDir, { recursive: true, force: true });
329
+ log.info('๐Ÿงน Off-site staging cleared. Source integrity preserved.');
330
+ }
331
+ }
332
+
333
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
334
+ console.log(`\x1b[36mHIPP - High Integrity Package Publisher\x1b[0m
335
+
336
+ Usage:
337
+ npx hipp [options] [-- npm-options]
338
+
339
+ Options:
340
+ -y, --yes Skip confirmation prompt
341
+ -h, --help Show this help
342
+
343
+ Integrity rules:
344
+ - package.json version must be 0.0.0
345
+ - package-lock.json must exist and be tracked
346
+ - npm ci --ignore-scripts --dry-run must succeed
347
+ - repository must be clean
348
+ - HEAD must be on a branch with an upstream
349
+ - HEAD must exactly match upstream
350
+ - HEAD must have an exact v-prefixed semver tag
351
+ - the exact tag must exist on origin and match locally
352
+ - HEAD commit must be contained in an origin remote branch
353
+ - only git-tracked files are staged
354
+ - only staged package.json is rewritten`);
355
+ } else {
356
+ run();
357
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@dk/hipp",
3
+ "version": "0.1.5",
4
+ "description": "High Integrity Package Publisher",
5
+ "main": "hipp.js",
6
+ "bin": {
7
+ "hipp": "./hipp.js"
8
+ },
9
+ "scripts": {
10
+ "hipp": "npm install && node hipp.js"
11
+ },
12
+ "author": "Dmytri Kleiner <dev@dmytri.to>",
13
+ "dependencies": {
14
+ "semver": ">=7.6.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "license": "0BSD"
23
+ }