Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes will appear in this file. The project follows Keep a Changel

## [Unreleased]

### Added

- Repeatable npm package verification and a maintainer publishing guide.

## [0.1.0] - 2026-07-15

First public release.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ Each real run starts from the current commit and creates:
| [Configuration](docs/configuration.md) | Schema, commands and quality gates |
| [Security](docs/security.md) | Process, prompt, credential and Git controls |
| [Development](docs/development.md) | Build, test and contribution workflow |
| [Publishing](docs/publishing.md) | npm package checks and release procedure |
| [Roadmap](docs/roadmap.md) | Planned scope and exclusions |

## Limitations
Expand Down
3 changes: 3 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ npm run typecheck
npm test
npm run test:coverage
npm run build
npm run pack:check
npm run format:check
```

Expand All @@ -23,3 +24,5 @@ node dist/index.js --help
node dist/index.js doctor
node dist/index.js run --dry-run --task "Smoke test"
```

Maintainers should follow the [npm publishing guide](publishing.md) before creating a release tag.
92 changes: 92 additions & 0 deletions docs/publishing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Publishing to npm

This guide is for maintainers publishing `loop-engineer`. Users do not need npm publisher access.

## Requirements

- An npm account with two-factor authentication
- Publish access to the `loop-engineer` package
- Node.js 20 or newer
- A clean checkout of the release commit

Check the account and package before changing a version:

```bash
npm login
npm whoami
npm view loop-engineer name version dist-tags --json
```

An npm `E404` response means the package has no public release at the time of the check. It does not reserve the name.

## Prepare the release

Update `package.json`, `package-lock.json` and `CHANGELOG.md` in the same pull request. The CLI version comes from `package.json`, so these commands must print the same version:

```bash
npm run build
head -1 dist/index.js
node dist/index.js --version
node -p "require('./package.json').version"
```

Run the full validation suite and inspect the package contract:

```bash
npm ci
npm run format:check
npm run lint
npm run typecheck
npm test
npm run test:coverage
npm run build
node scripts/verify-package.mjs
npm pack
```

The verifier requires the executable `dist/index.js`, its Node shebang, matching CLI and package versions, the GUI runtime assets and a tarball smaller than 2 MB. It rejects source files, tests and repository-only assets.

## Test the tarball

Install the generated file before publishing it:

```bash
npm install --global ./loop-engineer-0.1.0.tgz
loopeng --version
loopeng --help
```

Replace `0.1.0` with the version from `package.json`. Remove the test installation with `npm uninstall --global loop-engineer` if you plan to keep using `npm link` from a checkout.

## Publish

Publish from the reviewed release commit:

```bash
git status --short
npm publish --access public
npm view loop-engineer name version dist-tags --json
```

`git status --short` must print nothing. npm does not allow a publisher to replace an existing version. Fix a failed release with a new patch version instead of reusing the old version number.

Create and push the Git tag only after npm confirms the release:

```bash
git tag -a v0.1.0 -m "Loop Engineer v0.1.0"
git push origin v0.1.0
```

Create the matching GitHub release from that tag and copy the release section from `CHANGELOG.md` into the release notes.

## After publishing

Install from the registry in a clean environment:

```bash
npm install --global loop-engineer@0.1.0
loopeng --version
loopeng doctor
```

Then replace source-install instructions in the README and website with `npm install --global loop-engineer`.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"bugs": {
"url": "https://github.com/BotondCsereklye/LoopEngineer/issues"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20"
},
Expand Down Expand Up @@ -41,6 +44,9 @@
],
"scripts": {
"build": "tsc -p tsconfig.build.json && node scripts/copy-gui-assets.mjs",
"pack:check": "npm run build && node scripts/verify-package.mjs",
"prepack": "npm run build",
"prepublishOnly": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run test:coverage && npm run pack:check",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
Expand Down
104 changes: 104 additions & 0 deletions scripts/verify-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/* global process */

import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const MAX_PACKAGE_SIZE_BYTES = 2_000_000;
const REQUIRED_PATHS = [
'LICENSE',
'README.md',
'dist/index.js',
'dist/gui/public/index.html',
'dist/gui/public/app.js',
'dist/gui/public/styles.css',
'package.json',
];

function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

export function validatePackManifest(manifest) {
assert(manifest && typeof manifest === 'object', 'npm pack returned no package manifest.');
assert(manifest.name === 'loop-engineer', `Unexpected package name: ${manifest.name ?? 'none'}`);
assert(Array.isArray(manifest.files), 'npm pack returned no file list.');

const files = new Map(manifest.files.map((file) => [file.path, file]));
for (const requiredPath of REQUIRED_PATHS) {
assert(files.has(requiredPath), `Package is missing required file: ${requiredPath}`);
}

const unexpected = [...files.keys()].filter(
(filePath) =>
filePath !== 'LICENSE' &&
filePath !== 'README.md' &&
filePath !== 'package.json' &&
!filePath.startsWith('dist/'),
);
assert(
unexpected.length === 0,
`Package contains files outside the public contract: ${unexpected.join(', ')}`,
);
assert(
Number(manifest.size) <= MAX_PACKAGE_SIZE_BYTES,
`Packed tarball exceeds the 2 MB safety limit: ${manifest.size} bytes`,
);

const entrypoint = files.get('dist/index.js');
assert(
(Number(entrypoint.mode) & 0o111) !== 0,
'dist/index.js must be executable for the loopeng binary.',
);
}

export function verifyEntrypoint(root = process.cwd()) {
const packageJson = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
const relativeEntrypoint = packageJson.bin?.loopeng;
assert(typeof relativeEntrypoint === 'string', 'package.json must define the loopeng binary.');

const entrypoint = path.resolve(root, relativeEntrypoint);
const source = readFileSync(entrypoint, 'utf8');
assert(
source.startsWith('#!/usr/bin/env node\n'),
'dist/index.js must start with the Node shebang.',
);

const version = execFileSync(process.execPath, [entrypoint, '--version'], {
cwd: root,
encoding: 'utf8',
}).trim();
assert(
version === packageJson.version,
`CLI version ${version} does not match package version ${packageJson.version}.`,
);

return { entrypoint, version };
}

function main() {
const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
encoding: 'utf8',
});
const manifests = JSON.parse(output);
assert(manifests.length === 1, `Expected one package manifest, received ${manifests.length}.`);

const manifest = manifests[0];
validatePackManifest(manifest);
const { version } = verifyEntrypoint();
const size = new Intl.NumberFormat('en', { maximumFractionDigits: 1 }).format(
manifest.size / 1000,
);
process.stdout.write(
`Verified ${manifest.name}@${version}: ${manifest.entryCount} files, ${size} kB packed.\n`,
);
}

const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
if (fileURLToPath(import.meta.url) === invokedPath) {
main();
}
9 changes: 9 additions & 0 deletions site/documentation.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ <h1>Documentation</h1>
</td>
<td>Build, test, and contribution workflow</td>
</tr>
<tr>
<td>
<a
href="https://github.com/BotondCsereklye/LoopEngineer/blob/main/docs/publishing.md"
>Publishing</a
>
</td>
<td>npm package checks and release procedure</td>
</tr>
<tr>
<td>
<a href="https://github.com/BotondCsereklye/LoopEngineer/blob/main/docs/roadmap.md"
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/package-verifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
// Build tooling stays in scripts/ and does not ship as a TypeScript library.
// @ts-expect-error The verifier is an ESM build script with no declaration file.
import { validatePackManifest } from '../../scripts/verify-package.mjs';

const validManifest = {
name: 'loop-engineer',
version: '0.1.0',
size: 68_447,
files: [
{ path: 'LICENSE', mode: 0o644 },
{ path: 'README.md', mode: 0o644 },
{ path: 'dist/index.js', mode: 0o755 },
{ path: 'dist/gui/public/app.js', mode: 0o644 },
{ path: 'dist/gui/public/index.html', mode: 0o644 },
{ path: 'dist/gui/public/styles.css', mode: 0o644 },
{ path: 'package.json', mode: 0o644 },
],
};

describe('npm package verifier', () => {
it('accepts the publishable package contract', () => {
expect(() => validatePackManifest(validManifest)).not.toThrow();
});

it('rejects source files and missing runtime assets', () => {
expect(() =>
validatePackManifest({
...validManifest,
files: validManifest.files
.filter((file) => file.path !== 'dist/gui/public/index.html')
.concat({ path: 'src/index.ts', mode: 0o644 }),
}),
).toThrow(/dist\/gui\/public\/index\.html/);
});

it('rejects a non-executable CLI entrypoint', () => {
expect(() =>
validatePackManifest({
...validManifest,
files: validManifest.files.map((file) =>
file.path === 'dist/index.js' ? { ...file, mode: 0o644 } : file,
),
}),
).toThrow(/executable/);
});

it('rejects an unexpectedly large tarball', () => {
expect(() => validatePackManifest({ ...validManifest, size: 2_000_001 })).toThrow(/2 MB/);
});
});
Loading