@avivbenhamo/polly-kit 0.8.0 → 0.9.0

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/README.md CHANGED
@@ -71,7 +71,17 @@ body, so a bare `startPolly()` call in `index.tsx` runs *after* `./App` and ever
71
71
  imports — and any module that fires a request at import time would escape interception,
72
72
  silently and only sometimes.
73
73
 
74
- Add `/recordings` to `.gitignore`. A HAR holds complete response bodies.
74
+ Add the recordings directory to `.gitignore` — a HAR holds complete response bodies:
75
+
76
+ ```sh
77
+ npx polly-kit init # adds /recordings, or /apps/web/recordings
78
+ npx polly-kit init --dir fixtures/har # when that is not where you record
79
+ ```
80
+
81
+ Run it from the directory the app records in. The entry is anchored at the repository root
82
+ and written out in full — `/apps/web/recordings` from a package inside a monorepo, not
83
+ `/recordings`, which would match nothing there and look like it had. An entry already in the
84
+ file, in any spelling git honours, is left alone; running it twice adds one line.
75
85
 
76
86
  ## Running it
77
87
 
@@ -135,7 +145,7 @@ $env:POLLY_MODE='record'; npm run start:dev
135
145
  ```
136
146
 
137
147
  No Vite plugin and no recording store: `persister-fs` writes the HAR straight to disk.
138
- Add `/recordings` to `.gitignore`.
148
+ `npx polly-kit init` gitignores the directory.
139
149
 
140
150
  **Use `{kind: 'only'}`, not `{kind: 'all'}`.** In a browser Polly sees the page's own
141
151
  requests. In Node the adapters are process-wide: they also see your service talking to
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
  export declare function main(argv: string[]): Promise<number>;
3
+ export interface IgnoreOptions {
4
+ /** Where the command was run. The repository is found by walking up from here. */
5
+ cwd?: string;
6
+ /** The recordings directory, as passed to `startPolly`. Default `recordings`. */
7
+ dir?: string;
8
+ }
9
+ export interface IgnoreResult {
10
+ /**
11
+ * `added` — the entry was appended.
12
+ * `covered` — a rule already matching the directory was there; nothing written.
13
+ * `outside` — the directory is not under the repository, so no rule can name it.
14
+ * `root` — the directory *is* the repository, which is not something to ignore.
15
+ */
16
+ action: 'added' | 'covered' | 'outside' | 'root';
17
+ /** The `.gitignore` written, or the one that would have been. */
18
+ path: string;
19
+ /** The entry, as written or as it would be written. Empty when `outside`. */
20
+ entry: string;
21
+ /** The repository root this was resolved against. */
22
+ root: string;
23
+ /** The recordings directory, absolute. */
24
+ recordingsDir: string;
25
+ }
26
+ /**
27
+ * Adds the recordings directory to the repository's `.gitignore`.
28
+ *
29
+ * Two things here are deliberate and were the reason for the command.
30
+ *
31
+ * The entry is anchored at the repository root and spelled out in full —
32
+ * `/apps/web/recordings`, not `/recordings` — because the recordings directory
33
+ * is configured in the app's own code and so lives wherever the app does. A bare
34
+ * `/recordings` at the root of a monorepo matches nothing in `apps/web`, and the
35
+ * failure is silent: the file looks like it handled them and the HARs get
36
+ * committed anyway.
37
+ *
38
+ * Coverage is checked against every spelling git would honour, because the entry
39
+ * has been written by hand from the README for longer than this command has
40
+ * existed, and this package's own docs have asked for it as both `/recordings`
41
+ * and `recordings/`. `recordings` and `recordings/` have no leading slash, so git
42
+ * matches them at any depth and they already cover the directory — appending a
43
+ * second rule under one of them is a duplicate somebody then has to delete.
44
+ *
45
+ * Negated (`!recordings`) and commented-out entries are not coverage. Reading
46
+ * either as "already handled" would leave the recordings committed.
47
+ */
48
+ export declare function ignoreRecordings(options?: IgnoreOptions): IgnoreResult;
3
49
  export interface StudioOptions {
4
50
  recordingsDir: string;
5
51
  port?: number;
package/dist/cli.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The studio, served on its own, for a checkout with no app running.
3
+ * The two things that are done to a checkout rather than inside an app: serving
4
+ * the studio with no app running (`studio`), and keeping the recordings out of
5
+ * git (`init`).
6
+ *
7
+ * On `init`, see `ignoreRecordings`. The rest of this is the studio.
4
8
  *
5
9
  * The mounted form stays the default and is the better one when there *is* a dev
6
10
  * server: `app.use('/__polly', pollyUi(...))` — or the Vite plugin, which does
@@ -18,8 +22,8 @@
18
22
  * see those.
19
23
  */
20
24
  import { createServer } from 'node:http';
21
- import { existsSync } from 'node:fs';
22
- import { isAbsolute, join, resolve } from 'node:path';
25
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
23
27
  import { fileURLToPath } from 'node:url';
24
28
  import { pollyUi } from './studio/server.js';
25
29
  const DEFAULT_PORT = 7788;
@@ -33,11 +37,16 @@ const PORT_TRIES = 20;
33
37
  const USAGE = `polly-kit — Polly recordings, in a browser
34
38
 
35
39
  npx polly-kit studio [--dir DIR] [--port PORT]
40
+ npx polly-kit init [--dir DIR]
36
41
 
37
42
  studio Serve the recording editor on 127.0.0.1. Reads and writes the HAR
38
43
  files under DIR (default ${DEFAULT_DIR}), the same ones the mounted
39
44
  editor edits.
40
45
 
46
+ init Add DIR to the repository's .gitignore. A HAR holds complete
47
+ request and response bodies, so recordings are not something to
48
+ commit by accident. Safe to run twice.
49
+
41
50
  Mounted in a dev server instead — no second port, and it stops when the server
42
51
  does — this is the Vite plugin, or app.use('/__polly', pollyUi({recordingsDir})).
43
52
  Recordings held in a browser's localStorage are reachable only from the page
@@ -49,6 +58,24 @@ export async function main(argv) {
49
58
  process.stdout.write(USAGE);
50
59
  return 0;
51
60
  }
61
+ if (command === 'init' || command === 'setup') {
62
+ const result = ignoreRecordings({ dir: flag(argv, 'dir') });
63
+ // Every outcome is exit 0. This runs from a README in some trees and from a
64
+ // postinstall in others, and "already done" is the steady state in both.
65
+ if (result.action === 'added') {
66
+ process.stdout.write(`added to ${result.path}: ${result.entry}\n`);
67
+ }
68
+ else if (result.action === 'covered') {
69
+ process.stdout.write(`${result.entry} is already ignored by ${result.path}\n`);
70
+ }
71
+ else if (result.action === 'root') {
72
+ process.stdout.write(`${result.root} is the repository root, not a recordings directory — pass --dir\n`);
73
+ }
74
+ else {
75
+ process.stdout.write(`${result.recordingsDir} is outside ${result.root} — nothing to ignore there\n`);
76
+ }
77
+ return 0;
78
+ }
52
79
  if (command !== 'studio') {
53
80
  process.stderr.write(`unknown command: ${command}\n\n${USAGE}`);
54
81
  return 2;
@@ -79,6 +106,93 @@ export async function main(argv) {
79
106
  await studio.close();
80
107
  return 0;
81
108
  }
109
+ const IGNORE_COMMENT = '# @avivbenhamo/polly-kit: recorded traffic. A HAR holds complete request and\n# response bodies, including anything the responses carried.';
110
+ /**
111
+ * Adds the recordings directory to the repository's `.gitignore`.
112
+ *
113
+ * Two things here are deliberate and were the reason for the command.
114
+ *
115
+ * The entry is anchored at the repository root and spelled out in full —
116
+ * `/apps/web/recordings`, not `/recordings` — because the recordings directory
117
+ * is configured in the app's own code and so lives wherever the app does. A bare
118
+ * `/recordings` at the root of a monorepo matches nothing in `apps/web`, and the
119
+ * failure is silent: the file looks like it handled them and the HARs get
120
+ * committed anyway.
121
+ *
122
+ * Coverage is checked against every spelling git would honour, because the entry
123
+ * has been written by hand from the README for longer than this command has
124
+ * existed, and this package's own docs have asked for it as both `/recordings`
125
+ * and `recordings/`. `recordings` and `recordings/` have no leading slash, so git
126
+ * matches them at any depth and they already cover the directory — appending a
127
+ * second rule under one of them is a duplicate somebody then has to delete.
128
+ *
129
+ * Negated (`!recordings`) and commented-out entries are not coverage. Reading
130
+ * either as "already handled" would leave the recordings committed.
131
+ */
132
+ export function ignoreRecordings(options = {}) {
133
+ const cwd = options.cwd ?? process.cwd();
134
+ const dir = options.dir ?? DEFAULT_DIR;
135
+ const recordingsDir = isAbsolute(dir) ? resolve(dir) : resolve(cwd, dir);
136
+ // No git command is run: `.git` is a directory in a normal checkout and a file
137
+ // in a worktree or submodule, and either one marks the root. Falling back to
138
+ // cwd covers the directory that is not a repository yet.
139
+ const root = gitRoot(cwd) ?? resolve(cwd);
140
+ const path = join(root, '.gitignore');
141
+ const within = relative(root, recordingsDir);
142
+ if (within === '') {
143
+ return { action: 'root', path, entry: '', root, recordingsDir };
144
+ }
145
+ if (within.startsWith('..') || isAbsolute(within)) {
146
+ return { action: 'outside', path, entry: '', root, recordingsDir };
147
+ }
148
+ const entry = `/${within.split(sep).join('/')}`;
149
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
150
+ if (covers(existing, entry)) {
151
+ return { action: 'covered', path, entry, root, recordingsDir };
152
+ }
153
+ // The file's own line ending, not the platform's: git on Windows checks out a
154
+ // CRLF `.gitignore`, and appending LF lines to it shows the whole file as
155
+ // changed in the diff.
156
+ const eol = existing.includes('\r\n') ? '\r\n' : '\n';
157
+ const lead = existing === '' || /(\r?\n){2}$/.test(existing)
158
+ ? ''
159
+ : /\r?\n$/.test(existing)
160
+ ? eol
161
+ : `${eol}${eol}`;
162
+ const block = `${IGNORE_COMMENT}\n${entry}\n`.replace(/\n/g, eol);
163
+ writeFileSync(path, `${existing}${lead}${block}`, 'utf8');
164
+ return { action: 'added', path, entry, root, recordingsDir };
165
+ }
166
+ /** Nearest ancestor of `from` holding a `.git`, itself included. */
167
+ function gitRoot(from) {
168
+ let at = resolve(from);
169
+ for (;;) {
170
+ if (existsSync(join(at, '.git')))
171
+ return at;
172
+ const up = dirname(at);
173
+ if (up === at)
174
+ return undefined;
175
+ at = up;
176
+ }
177
+ }
178
+ /** Does any rule in `contents` already ignore `entry` (`/a/b/recordings`)? */
179
+ function covers(contents, entry) {
180
+ const wanted = entry.replace(/^\/+/, '').replace(/\/+$/, '');
181
+ const leaf = wanted.slice(wanted.lastIndexOf('/') + 1);
182
+ return contents.split(/\r?\n/).some((raw) => {
183
+ const line = raw.trim();
184
+ if (line === '' || line.startsWith('#') || line.startsWith('!'))
185
+ return false;
186
+ // A leading `**/` is git's explicit spelling of "at any depth", which is
187
+ // what a bare name means anyway.
188
+ const rule = line.replace(/^\*\*\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
189
+ // Anchored by a leading slash, or by a slash anywhere inside it: git matches
190
+ // those against the path from the .gitignore's own directory, so it has to be
191
+ // the same path. Otherwise the rule matches at every depth and the last
192
+ // segment of the path is enough.
193
+ return line.startsWith('/') || rule.includes('/') ? rule === wanted : rule === leaf;
194
+ });
195
+ }
82
196
  /**
83
197
  * The server, without the process around it.
84
198
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avivbenhamo/polly-kit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Drop-in record/replay of an app's HTTP traffic with Polly.JS, plus the polly-studio editor. Bring your own host policy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,7 @@
12
12
  "dist",
13
13
  "README.md"
14
14
  ],
15
- "//bin": "The studio on its own, for a checkout with no dev server to mount it in. The mounted form stays the default.",
15
+ "//bin": "`studio` on its own, for a checkout with no dev server to mount it in — the mounted form stays the default — and `init`, which gitignores the recordings directory.",
16
16
  "bin": {
17
17
  "polly-kit": "./dist/cli.js"
18
18
  },