footnote-data 0.1.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 +238 -0
- package/bin/footnote.mjs +6 -0
- package/footnote.mjs +536 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# footnote — the Footnote Data API from your terminal
|
|
2
|
+
|
|
3
|
+
Employment-diligence data from primary public records: statutory WARN layoff
|
|
4
|
+
notices, certified H-1B/LCA wage filings, USCIS petition outcomes, DOL Wage &
|
|
5
|
+
Hour and OSHA enforcement, federal litigation posture, and severance terms
|
|
6
|
+
parsed from SEC filings. Counts are measured, matches are exact-or-absent, and
|
|
7
|
+
every response carries `data_gaps` — absence of evidence is always
|
|
8
|
+
distinguished from evidence of absence.
|
|
9
|
+
|
|
10
|
+
Zero runtime dependencies. Node 18+. Docs and pricing: <https://usefootnote.com/data>.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# one-off
|
|
16
|
+
npx footnote-data warn recent --state CA
|
|
17
|
+
|
|
18
|
+
# global
|
|
19
|
+
npm install -g footnote-data
|
|
20
|
+
footnote warn recent --state CA
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# 1. Create a free account at https://usefootnote.com
|
|
27
|
+
# 2. Issue your API key at https://usefootnote.com/account (Data API section — shown once)
|
|
28
|
+
# 3. Put it in your environment:
|
|
29
|
+
export FOOTNOTE_API_KEY=fn_live_your_key_here
|
|
30
|
+
|
|
31
|
+
# First call: layoff notices filed in California in the last 30 days
|
|
32
|
+
footnote warn recent --state CA
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
No key yet? Any command tells you exactly how to get one:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
$ footnote warn recent
|
|
39
|
+
Pass your API key as: Authorization: Bearer fn_live_…
|
|
40
|
+
|
|
41
|
+
Get a free API key (50 calls/day) in three steps:
|
|
42
|
+
1. Create a free account at https://usefootnote.com
|
|
43
|
+
2. Issue your key at https://usefootnote.com/account (Data API section — the key is shown once)
|
|
44
|
+
3. export FOOTNOTE_API_KEY=fn_live_… (or pass --key fn_live_…)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
Sample outputs below are illustrative of the response shape — run the command
|
|
50
|
+
for live data.
|
|
51
|
+
|
|
52
|
+
### WARN layoff notices
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Recent notices, newest first (free tier: last 30 days)
|
|
56
|
+
footnote warn recent --state CA --days 14 --limit 25
|
|
57
|
+
|
|
58
|
+
# Search by employer across states with a date window and pagination
|
|
59
|
+
footnote warn notices --employer "Boeing" --since 2026-06-01 --limit 50
|
|
60
|
+
footnote warn notices --state TX --until 2026-07-01 --offset 100
|
|
61
|
+
|
|
62
|
+
# Per-state summary: notice counts, workers affected, latest filing
|
|
63
|
+
footnote warn states
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
$ footnote warn recent --state CA --limit 2
|
|
68
|
+
employer_name_raw state_code notice_date effective_date workers_affected city layoff_type source_url
|
|
69
|
+
─────────────────────── ────────── ─────────── ────────────── ──────────────── ────────── ─────────── ────────────────────────────
|
|
70
|
+
Example Manufacturing CA 2026-07-10 2026-09-08 214 Fremont Closure https://edd.ca.gov/...
|
|
71
|
+
Sample Logistics Co CA 2026-07-09 — 88 Ontario Layoff https://edd.ca.gov/...
|
|
72
|
+
|
|
73
|
+
count: 2 · window_days: 30 · state: CA · tier: free
|
|
74
|
+
source: Official state WARN publications, ingested daily by Footnote (usefootnote.com)
|
|
75
|
+
license: CC BY 4.0 for display with attribution; no redistribution as a dataset/API
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### H-1B sponsorship & wages
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Does this employer sponsor? (certified LCA corpus)
|
|
82
|
+
footnote h1b employer "Databricks"
|
|
83
|
+
|
|
84
|
+
# Top sponsors by certified filing volume (free tier: top 25)
|
|
85
|
+
footnote h1b top-sponsors --limit 10
|
|
86
|
+
|
|
87
|
+
# Petition OUTCOMES (USCIS) + current attestation INTENT (LCA), in one view
|
|
88
|
+
footnote sponsorship "Meta Platforms"
|
|
89
|
+
|
|
90
|
+
# Advertised (job posting) vs DOL-attested wages, same occupation and area
|
|
91
|
+
footnote wage-comparison "Example Corp"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
$ footnote sponsorship "Example Corp"
|
|
96
|
+
employer example corp
|
|
97
|
+
matched_as entity
|
|
98
|
+
matched true
|
|
99
|
+
posture growing
|
|
100
|
+
latest_fiscal_year.fiscal_year 2023
|
|
101
|
+
latest_fiscal_year.initial_petition_approvals 412
|
|
102
|
+
current_attestation.matched true
|
|
103
|
+
current_attestation.fiscal_year 2026
|
|
104
|
+
current_attestation.certified_filings 380
|
|
105
|
+
data_gaps (none)
|
|
106
|
+
|
|
107
|
+
tier: free
|
|
108
|
+
▲ upgrade: Full fiscal-year series and denial rates are on Data Pro — usefootnote.com/data
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Employer enforcement & filings records
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# DOL Wage & Hour enforcement posture (concluded cases, exact-name matched)
|
|
115
|
+
footnote enforcement "Example Restaurants LLC"
|
|
116
|
+
|
|
117
|
+
# OSHA inspections and citations
|
|
118
|
+
footnote osha "Example Warehousing"
|
|
119
|
+
|
|
120
|
+
# Federal litigation posture (CourtListener/RECAP; employer-level aggregates only)
|
|
121
|
+
footnote litigation "Example Corp"
|
|
122
|
+
|
|
123
|
+
# Executive severance / change-in-control terms from SEC Exhibit 10 filings
|
|
124
|
+
footnote severance "Example Corp"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Dataset provenance
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Row counts, coverage windows, and freshness — measured live at request time
|
|
131
|
+
footnote datasets
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Built for citations: the response includes a suggested footnote of the form
|
|
135
|
+
`"Footnote Data API (usefootnote.com/data), retrieved <date>; dataset: <dataset>."`
|
|
136
|
+
|
|
137
|
+
### `footnote get` — works with endpoints that don't exist yet
|
|
138
|
+
|
|
139
|
+
The passthrough issues an authenticated GET against any path under the API, so
|
|
140
|
+
when Footnote ships a new endpoint the CLI already supports it — no update
|
|
141
|
+
required. This is the auto-upgrade property: the CLI grows with the API.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Any endpoint, current or future
|
|
145
|
+
footnote get /api/data/v1/warn/recent state=CA limit=5
|
|
146
|
+
|
|
147
|
+
# Discover every endpoint from the live OpenAPI contract (public, no key needed)
|
|
148
|
+
footnote get /api/data/v1/openapi.json
|
|
149
|
+
|
|
150
|
+
# CSV export (Data Pro) — text bodies pass through untouched
|
|
151
|
+
footnote get /api/data/v1/warn/export.csv > warn-24mo.csv
|
|
152
|
+
|
|
153
|
+
# Full historical archive (one-time purchase)
|
|
154
|
+
footnote get /api/data/v1/warn/historical.csv > warn-all.csv
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Output modes
|
|
158
|
+
|
|
159
|
+
- **TTY**: aligned tables for lists, key/value views for lookups, meta printed
|
|
160
|
+
beneath.
|
|
161
|
+
- **`--json` or piped**: the raw response body, exactly as the API returned it.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
footnote warn states --json | jq '.data[0]'
|
|
165
|
+
footnote h1b top-sponsors | jq '.data[].employer_name' # piped ⇒ JSON automatically
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Exit codes: `0` success · `1` API or network error · `2` usage error.
|
|
169
|
+
|
|
170
|
+
## Rate limits
|
|
171
|
+
|
|
172
|
+
| Tier | Calls/day | Burst | WARN window | Price |
|
|
173
|
+
|---|---|---|---|---|
|
|
174
|
+
| Free | 50 | 10/min | 30 days | $0 |
|
|
175
|
+
| Data Pro | 10,000 | 120/min | 24 months | see [usefootnote.com/data](https://usefootnote.com/data) |
|
|
176
|
+
| Enterprise | 100,000 | 600/min | full archive | contact |
|
|
177
|
+
|
|
178
|
+
Hitting the free cap is the product working — the CLI shows you the way up:
|
|
179
|
+
|
|
180
|
+
```text
|
|
181
|
+
╭────────────────────────────────────────────────────────────────────────────╮
|
|
182
|
+
│ Daily cap reached (50/day on the free tier). │
|
|
183
|
+
│ │
|
|
184
|
+
│ The free tier is enough to confirm Footnote works inside your product. │
|
|
185
|
+
│ For production volume, Data Pro is 10,000 calls/day with a 24-month WARN │
|
|
186
|
+
│ window — $79/mo. │
|
|
187
|
+
│ │
|
|
188
|
+
│ Your daily meter resets at 2026-07-17T00:00:00.000Z. │
|
|
189
|
+
│ │
|
|
190
|
+
│ https://usefootnote.com/data#pricing │
|
|
191
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Configuration
|
|
195
|
+
|
|
196
|
+
| Setting | Env var | Flag | Default |
|
|
197
|
+
|---|---|---|---|
|
|
198
|
+
| API key | `FOOTNOTE_API_KEY` | `--key fn_live_…` | — |
|
|
199
|
+
| API origin | `FOOTNOTE_API_URL` | `--api-url https://…` | `https://usefootnote.com` |
|
|
200
|
+
| Colors | `NO_COLOR` | `--no-color` | on for TTY |
|
|
201
|
+
|
|
202
|
+
## MCP / AI assistants
|
|
203
|
+
|
|
204
|
+
An MCP server (`@footnote/mcp`) exposing these endpoints as tools is planned.
|
|
205
|
+
Today, point any OpenAPI-capable agent or tool at the live machine-readable
|
|
206
|
+
contract:
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
https://usefootnote.com/api/data/v1/openapi.json
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## License & attribution
|
|
213
|
+
|
|
214
|
+
CLI code: MIT.
|
|
215
|
+
|
|
216
|
+
Data returned on the **free tier** is licensed **CC BY 4.0 for display with
|
|
217
|
+
attribution** — credit `Data: Footnote (usefootnote.com)` where you show it;
|
|
218
|
+
redistribution as a dataset or API is not permitted. Paid tiers are governed by
|
|
219
|
+
your Footnote Data agreement. Every response's `meta.license` states the terms
|
|
220
|
+
that apply to that call.
|
|
221
|
+
|
|
222
|
+
## Endpoint reference
|
|
223
|
+
|
|
224
|
+
| Command | Endpoint |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `warn recent` | `GET /api/data/v1/warn/recent` |
|
|
227
|
+
| `warn notices` | `GET /api/data/v1/warn/notices` |
|
|
228
|
+
| `warn states` | `GET /api/data/v1/warn/states` |
|
|
229
|
+
| `h1b employer` | `GET /api/data/v1/h1b/employer` |
|
|
230
|
+
| `h1b top-sponsors` | `GET /api/data/v1/h1b/top-sponsors` |
|
|
231
|
+
| `sponsorship` | `GET /api/data/v1/sponsorship/employer` |
|
|
232
|
+
| `enforcement` | `GET /api/data/v1/enforcement/employer` |
|
|
233
|
+
| `litigation` | `GET /api/data/v1/litigation/employer` |
|
|
234
|
+
| `osha` | `GET /api/data/v1/osha/employer` |
|
|
235
|
+
| `severance` | `GET /api/data/v1/severance/employer` |
|
|
236
|
+
| `wage-comparison` | `GET /api/data/v1/wage-comparison/employer` |
|
|
237
|
+
| `datasets` | `GET /api/data/v1/datasets` |
|
|
238
|
+
| `get <path>` | `GET <any /api/data/v1 path>` |
|
package/bin/footnote.mjs
ADDED
package/footnote.mjs
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* footnote — the Footnote Data API from your terminal (https://usefootnote.com/data).
|
|
3
|
+
*
|
|
4
|
+
* Zero runtime dependencies; Node >= 18 (global fetch). Every command spec below
|
|
5
|
+
* mirrors the server route file it names — params here are the route's REAL query
|
|
6
|
+
* params, read from the source, never guessed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const VERSION = "0.1.0";
|
|
10
|
+
export const DEFAULT_API_URL = "https://usefootnote.com";
|
|
11
|
+
|
|
12
|
+
const UPGRADE_URL = "https://usefootnote.com/data";
|
|
13
|
+
const KEYS_URL = "https://usefootnote.com/account";
|
|
14
|
+
// Mirrors TIER_LIMITS in lib/data-api/authorize.ts (pro: 10k/day, 120/min, 730-day window).
|
|
15
|
+
const DEFAULT_UPGRADE_TEXT = "Data Pro: 10,000 calls/day · 120 req/min · 24-month WARN window.";
|
|
16
|
+
|
|
17
|
+
const CELL_MAX = 48;
|
|
18
|
+
|
|
19
|
+
/* ---------------------------------- styling ---------------------------------- */
|
|
20
|
+
|
|
21
|
+
const PLAIN = { bold: (s) => s, dim: (s) => s, cyan: (s) => s, yellow: (s) => s };
|
|
22
|
+
|
|
23
|
+
function makeStyle(enabled) {
|
|
24
|
+
if (!enabled) return PLAIN;
|
|
25
|
+
const wrap = (open, close) => (s) => `\u001b[${open}m${s}\u001b[${close}m`;
|
|
26
|
+
return { bold: wrap(1, 22), dim: wrap(2, 22), cyan: wrap(36, 39), yellow: wrap(33, 39) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ------------------------------ argument parsing ------------------------------ */
|
|
30
|
+
|
|
31
|
+
const VALIDATORS = {
|
|
32
|
+
state: {
|
|
33
|
+
ok: (v) => /^[A-Za-z]{2}$/.test(v),
|
|
34
|
+
hint: "a two-letter U.S. state code (e.g. CA)",
|
|
35
|
+
map: (v) => v.toUpperCase(),
|
|
36
|
+
},
|
|
37
|
+
posInt: { ok: (v) => /^\d+$/.test(v) && Number(v) >= 1, hint: "a positive integer" },
|
|
38
|
+
nonNegInt: { ok: (v) => /^\d+$/.test(v), hint: "a non-negative integer" },
|
|
39
|
+
date: { ok: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v), hint: "a date formatted YYYY-MM-DD" },
|
|
40
|
+
text3: { ok: (v) => v.trim().length >= 3, hint: "at least 3 characters" },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The command registry. `route` names the server file each spec was read from;
|
|
45
|
+
* `flags`/`positional` are that route's actual accepted params.
|
|
46
|
+
*/
|
|
47
|
+
export const COMMANDS = [
|
|
48
|
+
{
|
|
49
|
+
name: "warn recent",
|
|
50
|
+
path: "/api/data/v1/warn/recent",
|
|
51
|
+
route: "app/api/data/v1/warn/recent/route.ts",
|
|
52
|
+
summary: "Recent WARN layoff notices, newest first",
|
|
53
|
+
flags: { state: "state", days: "posInt", limit: "posInt" },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "warn notices",
|
|
57
|
+
path: "/api/data/v1/warn/notices",
|
|
58
|
+
route: "app/api/data/v1/warn/notices/route.ts",
|
|
59
|
+
summary: "Search WARN notices (employer, state, date window, pagination)",
|
|
60
|
+
flags: { state: "state", employer: "text3", since: "date", until: "date", limit: "posInt", offset: "nonNegInt" },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "warn states",
|
|
64
|
+
path: "/api/data/v1/warn/states",
|
|
65
|
+
route: "app/api/data/v1/warn/states/route.ts",
|
|
66
|
+
summary: "Per-state WARN summaries over your tier's window",
|
|
67
|
+
flags: {},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "h1b employer",
|
|
71
|
+
path: "/api/data/v1/h1b/employer",
|
|
72
|
+
route: "app/api/data/v1/h1b/employer/route.ts",
|
|
73
|
+
summary: "H-1B sponsorship lookup over certified LCA filings",
|
|
74
|
+
positional: { param: "name", label: "<company>", min: 3 },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "h1b top-sponsors",
|
|
78
|
+
path: "/api/data/v1/h1b/top-sponsors",
|
|
79
|
+
route: "app/api/data/v1/h1b/top-sponsors/route.ts",
|
|
80
|
+
summary: "Top H-1B sponsors by certified filing volume",
|
|
81
|
+
flags: { limit: "posInt" },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "sponsorship",
|
|
85
|
+
path: "/api/data/v1/sponsorship/employer",
|
|
86
|
+
route: "app/api/data/v1/sponsorship/employer/route.ts",
|
|
87
|
+
summary: "USCIS petition outcomes + current LCA attestation intent",
|
|
88
|
+
positional: { param: "name", label: "<employer>", min: 2, max: 160 },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "enforcement",
|
|
92
|
+
path: "/api/data/v1/enforcement/employer",
|
|
93
|
+
route: "app/api/data/v1/enforcement/employer/route.ts",
|
|
94
|
+
summary: "DOL Wage & Hour enforcement posture",
|
|
95
|
+
positional: { param: "name", label: "<employer>", min: 2 },
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "litigation",
|
|
99
|
+
path: "/api/data/v1/litigation/employer",
|
|
100
|
+
route: "app/api/data/v1/litigation/employer/route.ts",
|
|
101
|
+
summary: "Federal litigation posture (CourtListener/RECAP)",
|
|
102
|
+
positional: { param: "name", label: "<employer>", min: 2 },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "osha",
|
|
106
|
+
path: "/api/data/v1/osha/employer",
|
|
107
|
+
route: "app/api/data/v1/osha/employer/route.ts",
|
|
108
|
+
summary: "OSHA inspections and citations",
|
|
109
|
+
positional: { param: "name", label: "<employer>", min: 2 },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "severance",
|
|
113
|
+
path: "/api/data/v1/severance/employer",
|
|
114
|
+
route: "app/api/data/v1/severance/employer/route.ts",
|
|
115
|
+
summary: "Executive severance terms parsed from SEC Exhibit 10 filings",
|
|
116
|
+
positional: { param: "name", label: "<employer>", min: 2 },
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "wage-comparison",
|
|
120
|
+
path: "/api/data/v1/wage-comparison/employer",
|
|
121
|
+
route: "app/api/data/v1/wage-comparison/employer/route.ts",
|
|
122
|
+
summary: "Advertised (posting) vs DOL-attested (LCA) wages",
|
|
123
|
+
positional: { param: "name", label: "<employer>", min: 2 },
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "datasets",
|
|
127
|
+
path: "/api/data/v1/datasets",
|
|
128
|
+
route: "app/api/data/v1/datasets/route.ts",
|
|
129
|
+
summary: "Live provenance index: row counts, coverage, freshness",
|
|
130
|
+
flags: {},
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function findCommand(words) {
|
|
135
|
+
const two = words.slice(0, 2).join(" ");
|
|
136
|
+
const one = words[0];
|
|
137
|
+
return (
|
|
138
|
+
COMMANDS.find((c) => c.name === two) ??
|
|
139
|
+
COMMANDS.find((c) => c.name === one) ??
|
|
140
|
+
null
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Splits global flags from command words. Returns { globals, words } or
|
|
146
|
+
* { usageError } when a flag is malformed.
|
|
147
|
+
*/
|
|
148
|
+
export function parseGlobals(argv) {
|
|
149
|
+
const globals = { json: false, key: null, apiUrl: null, noColor: false, help: false, version: false };
|
|
150
|
+
const words = [];
|
|
151
|
+
for (let i = 0; i < argv.length; i++) {
|
|
152
|
+
const a = argv[i];
|
|
153
|
+
if (a === "--json") globals.json = true;
|
|
154
|
+
else if (a === "--no-color") globals.noColor = true;
|
|
155
|
+
else if (a === "--help" || a === "-h") globals.help = true;
|
|
156
|
+
else if (a === "--version" || a === "-V") globals.version = true;
|
|
157
|
+
else if (a === "--key" || a === "--api-url") {
|
|
158
|
+
const v = argv[++i];
|
|
159
|
+
if (v === undefined) return { usageError: `${a} needs a value` };
|
|
160
|
+
if (a === "--key") globals.key = v;
|
|
161
|
+
else globals.apiUrl = v;
|
|
162
|
+
} else if (a.startsWith("--key=")) globals.key = a.slice("--key=".length);
|
|
163
|
+
else if (a.startsWith("--api-url=")) globals.apiUrl = a.slice("--api-url=".length);
|
|
164
|
+
else words.push(a);
|
|
165
|
+
}
|
|
166
|
+
return { globals, words };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parses one command's tokens into query params. Returns
|
|
171
|
+
* { path, params: [k, v][] } or { usageError }.
|
|
172
|
+
*/
|
|
173
|
+
export function parseCommand(words) {
|
|
174
|
+
if (words[0] === "get") return parsePassthrough(words.slice(1));
|
|
175
|
+
|
|
176
|
+
const cmd = findCommand(words);
|
|
177
|
+
if (!cmd) return { usageError: `unknown command: ${words.join(" ")}` };
|
|
178
|
+
|
|
179
|
+
const rest = words.slice(cmd.name.split(" ").length);
|
|
180
|
+
const params = new Map();
|
|
181
|
+
const positionalWords = [];
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < rest.length; i++) {
|
|
184
|
+
const token = rest[i];
|
|
185
|
+
if (token.startsWith("--")) {
|
|
186
|
+
const eq = token.indexOf("=");
|
|
187
|
+
const flag = eq === -1 ? token.slice(2) : token.slice(2, eq);
|
|
188
|
+
const spec = (cmd.flags ?? {})[flag];
|
|
189
|
+
if (!spec) return { usageError: `unknown flag --${flag} for "${cmd.name}"` };
|
|
190
|
+
const value = eq === -1 ? rest[++i] : token.slice(eq + 1);
|
|
191
|
+
if (value === undefined) return { usageError: `--${flag} needs a value` };
|
|
192
|
+
const validator = VALIDATORS[spec];
|
|
193
|
+
if (!validator.ok(value)) return { usageError: `--${flag} must be ${validator.hint} (got "${value}")` };
|
|
194
|
+
params.set(flag, validator.map ? validator.map(value) : value);
|
|
195
|
+
} else {
|
|
196
|
+
if (!cmd.positional) return { usageError: `unexpected argument "${token}" for "${cmd.name}"` };
|
|
197
|
+
positionalWords.push(token);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (cmd.positional) {
|
|
202
|
+
const value = positionalWords.join(" ").trim();
|
|
203
|
+
const { param, label, min, max } = cmd.positional;
|
|
204
|
+
if (!value) return { usageError: `"${cmd.name}" needs ${label}, e.g. footnote ${cmd.name} "Acme Corp"` };
|
|
205
|
+
if (value.length < min) return { usageError: `${label} needs at least ${min} characters (got "${value}")` };
|
|
206
|
+
if (max && value.length > max) return { usageError: `${label} must be at most ${max} characters` };
|
|
207
|
+
params.set(param, value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { path: cmd.path, params: [...params.entries()] };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parsePassthrough(tokens) {
|
|
214
|
+
const path = tokens[0];
|
|
215
|
+
if (!path) return { usageError: "get needs a path, e.g. footnote get /api/data/v1/warn/recent state=CA" };
|
|
216
|
+
if (!path.startsWith("/") || path.includes("://") || !/^\/[A-Za-z0-9/_.\-]*$/.test(path)) {
|
|
217
|
+
return { usageError: `get takes an absolute API path like /api/data/v1/..., not "${path}"` };
|
|
218
|
+
}
|
|
219
|
+
const params = [];
|
|
220
|
+
for (const token of tokens.slice(1)) {
|
|
221
|
+
const eq = token.indexOf("=");
|
|
222
|
+
if (eq <= 0) return { usageError: `get params are key=value pairs (got "${token}")` };
|
|
223
|
+
params.push([token.slice(0, eq), token.slice(eq + 1)]);
|
|
224
|
+
}
|
|
225
|
+
return { path, params };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Builds the request URL from an origin, a path, and [k, v] query pairs. */
|
|
229
|
+
export function buildUrl(apiUrl, path, params) {
|
|
230
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
231
|
+
const url = new URL(base + path);
|
|
232
|
+
for (const [k, v] of params) url.searchParams.append(k, v);
|
|
233
|
+
return url;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* -------------------------------- rendering -------------------------------- */
|
|
237
|
+
|
|
238
|
+
function truncateCell(s) {
|
|
239
|
+
return s.length > CELL_MAX ? `${s.slice(0, CELL_MAX - 1)}…` : s;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function formatCell(v) {
|
|
243
|
+
if (v === null || v === undefined) return "—";
|
|
244
|
+
if (typeof v === "object") return truncateCell(JSON.stringify(v));
|
|
245
|
+
return truncateCell(String(v));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Aligned text table over an array of objects (columns in first-appearance order). */
|
|
249
|
+
export function renderTable(rows, style = PLAIN) {
|
|
250
|
+
if (!rows.length) return "(no rows)";
|
|
251
|
+
const cols = [];
|
|
252
|
+
for (const row of rows) for (const k of Object.keys(row)) if (!cols.includes(k)) cols.push(k);
|
|
253
|
+
const cells = rows.map((row) => cols.map((c) => formatCell(row[c])));
|
|
254
|
+
const numeric = cols.map((c) => rows.every((r) => r[c] === null || r[c] === undefined || typeof r[c] === "number"));
|
|
255
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...cells.map((row) => row[i].length)));
|
|
256
|
+
const pad = (s, i) => (numeric[i] ? s.padStart(widths[i]) : s.padEnd(widths[i]));
|
|
257
|
+
const lines = [
|
|
258
|
+
style.bold(cols.map((c, i) => pad(c, i)).join(" ").trimEnd()),
|
|
259
|
+
style.dim(widths.map((w) => "─".repeat(w)).join(" ")),
|
|
260
|
+
...cells.map((row) => row.map((s, i) => pad(s, i)).join(" ").trimEnd()),
|
|
261
|
+
];
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Key/value view for object payloads; nested objects flatten one level, arrays of objects become sub-tables. */
|
|
266
|
+
export function renderObject(obj, style = PLAIN) {
|
|
267
|
+
const scalars = [];
|
|
268
|
+
const tables = [];
|
|
269
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
270
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
271
|
+
for (const [sub, sv] of Object.entries(v)) pushEntry(scalars, tables, `${k}.${sub}`, sv);
|
|
272
|
+
} else {
|
|
273
|
+
pushEntry(scalars, tables, k, v);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const width = scalars.reduce((w, [k]) => Math.max(w, k.length), 0);
|
|
277
|
+
const lines = scalars.map(([k, v]) => `${style.dim(k.padEnd(width))} ${v}`);
|
|
278
|
+
for (const [label, rows] of tables) lines.push("", style.bold(label), renderTable(rows, style));
|
|
279
|
+
return lines.join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pushEntry(scalars, tables, label, v) {
|
|
283
|
+
if (v === null || v === undefined) scalars.push([label, "—"]);
|
|
284
|
+
else if (Array.isArray(v)) {
|
|
285
|
+
if (v.length === 0) scalars.push([label, "(none)"]);
|
|
286
|
+
else if (typeof v[0] === "object" && v[0] !== null) tables.push([label, v]);
|
|
287
|
+
else scalars.push([label, v.join(", ")]);
|
|
288
|
+
} else if (typeof v === "object") scalars.push([label, truncateCell(JSON.stringify(v))]);
|
|
289
|
+
else scalars.push([label, String(v)]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function renderMeta(meta, style) {
|
|
293
|
+
if (!meta || typeof meta !== "object") return [];
|
|
294
|
+
const inline = ["count", "offset", "window_days", "window_start", "state", "tier"]
|
|
295
|
+
.filter((k) => meta[k] !== undefined && meta[k] !== null)
|
|
296
|
+
.map((k) => `${k}: ${meta[k]}`);
|
|
297
|
+
const lines = [];
|
|
298
|
+
if (inline.length) lines.push(style.dim(inline.join(" · ")));
|
|
299
|
+
for (const k of ["match", "units", "interpretation", "privacy", "note", "source", "license", "citation"]) {
|
|
300
|
+
if (typeof meta[k] === "string") lines.push(style.dim(`${k}: ${meta[k]}`));
|
|
301
|
+
}
|
|
302
|
+
if (typeof meta.upgrade === "string") lines.push(style.yellow(`▲ upgrade: ${meta.upgrade}`));
|
|
303
|
+
return lines;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function wrapText(text, width) {
|
|
307
|
+
const lines = [];
|
|
308
|
+
let line = "";
|
|
309
|
+
for (const word of String(text).split(/\s+/)) {
|
|
310
|
+
if (line && line.length + 1 + word.length > width) {
|
|
311
|
+
lines.push(line);
|
|
312
|
+
line = word;
|
|
313
|
+
} else line = line ? `${line} ${word}` : word;
|
|
314
|
+
}
|
|
315
|
+
if (line) lines.push(line);
|
|
316
|
+
return lines;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Box-drawn upgrade prompt; the URL always gets its own line. */
|
|
320
|
+
export function renderUpgradeBox({ message, upgradeText, upgradeUrl, resetsAt }, style = PLAIN) {
|
|
321
|
+
const lines = [
|
|
322
|
+
...wrapText(message ?? "", 72),
|
|
323
|
+
"",
|
|
324
|
+
...wrapText(upgradeText ?? "", 72),
|
|
325
|
+
...(resetsAt ? ["", `Your daily meter resets at ${resetsAt}.`] : []),
|
|
326
|
+
"",
|
|
327
|
+
upgradeUrl ?? "",
|
|
328
|
+
];
|
|
329
|
+
const width = Math.max(...lines.map((l) => l.length));
|
|
330
|
+
return [
|
|
331
|
+
`╭${"─".repeat(width + 4)}╮`,
|
|
332
|
+
...lines.map((l, i) => `│ ${(i === lines.length - 1 ? style.cyan(l.padEnd(width)) : l.padEnd(width))} │`),
|
|
333
|
+
`╰${"─".repeat(width + 4)}╯`,
|
|
334
|
+
].join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function upgradeFromBody(body) {
|
|
338
|
+
const raw = body?.upgrade ?? body?.error?.upgrade ?? body?.meta?.upgrade ?? null;
|
|
339
|
+
if (raw === null || raw === undefined) return null;
|
|
340
|
+
if (typeof raw === "string") {
|
|
341
|
+
const url = (raw.match(/https?:\/\/\S+/) ?? [])[0] ?? UPGRADE_URL;
|
|
342
|
+
return { upgradeText: raw, upgradeUrl: url };
|
|
343
|
+
}
|
|
344
|
+
if (typeof raw === "object") {
|
|
345
|
+
return {
|
|
346
|
+
upgradeText: String(raw.message ?? raw.text ?? DEFAULT_UPGRADE_TEXT),
|
|
347
|
+
upgradeUrl: String(raw.upgrade_url ?? raw.url ?? UPGRADE_URL),
|
|
348
|
+
resetsAt: typeof raw.resets_at === "string" ? raw.resets_at : null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderApiError(status, body, headers, io, style) {
|
|
355
|
+
const code = body?.error?.code ?? "";
|
|
356
|
+
const message = body?.error?.message ?? `API error (HTTP ${status})`;
|
|
357
|
+
const upgrade = upgradeFromBody(body);
|
|
358
|
+
|
|
359
|
+
if (status === 401) {
|
|
360
|
+
if (code === "invalid_key") {
|
|
361
|
+
io.err(message);
|
|
362
|
+
io.err(`Check your keys at ${style.cyan(KEYS_URL)} — a revoked key stops working immediately.`);
|
|
363
|
+
} else {
|
|
364
|
+
io.err(message);
|
|
365
|
+
io.err("");
|
|
366
|
+
io.err("Get a free API key (50 calls/day) in three steps:");
|
|
367
|
+
io.err(` 1. Create a free account at ${style.cyan("https://usefootnote.com")}`);
|
|
368
|
+
io.err(` 2. Issue your key at ${style.cyan(KEYS_URL)} (Data API section — the key is shown once)`);
|
|
369
|
+
io.err(" 3. export FOOTNOTE_API_KEY=fn_live_… (or pass --key fn_live_…)");
|
|
370
|
+
}
|
|
371
|
+
return 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Hitting a cap is the product working: render the upgrade path, prominently.
|
|
375
|
+
if ((status === 429 && (code === "daily_cap" || upgrade)) ||
|
|
376
|
+
(status === 403 && (code === "upgrade_required" || code === "purchase_required"))) {
|
|
377
|
+
io.err(renderUpgradeBox({
|
|
378
|
+
message,
|
|
379
|
+
upgradeText: upgrade?.upgradeText ?? DEFAULT_UPGRADE_TEXT,
|
|
380
|
+
upgradeUrl: upgrade?.upgradeUrl ?? UPGRADE_URL,
|
|
381
|
+
resetsAt: upgrade?.resetsAt ?? null,
|
|
382
|
+
}, style));
|
|
383
|
+
return 1;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (status === 429) {
|
|
387
|
+
const retry = headers?.get?.("retry-after");
|
|
388
|
+
io.err(retry ? `${message} Retry in ${retry}s.` : message);
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
io.err(`${message}${code ? ` [${code}]` : ""} (HTTP ${status})`);
|
|
393
|
+
return 1;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ---------------------------------- help ---------------------------------- */
|
|
397
|
+
|
|
398
|
+
export function helpText() {
|
|
399
|
+
const rows = COMMANDS.map((c) => {
|
|
400
|
+
const flags = Object.entries(c.flags ?? {})
|
|
401
|
+
.map(([f, t]) => `[--${f} ${t === "date" ? "YYYY-MM-DD" : t === "state" ? "CA" : t === "text3" ? "TEXT" : "N"}]`)
|
|
402
|
+
.join(" ");
|
|
403
|
+
const pos = c.positional ? ` ${c.positional.label}` : "";
|
|
404
|
+
return [` footnote ${c.name}${pos}${flags ? ` ${flags}` : ""}`, c.summary];
|
|
405
|
+
});
|
|
406
|
+
rows.push([" footnote get <path> [k=v ...]", "Raw GET against any /api/data/v1 endpoint (current or future)"]);
|
|
407
|
+
const width = rows.reduce((w, [l]) => Math.max(w, l.length), 0);
|
|
408
|
+
return [
|
|
409
|
+
`footnote v${VERSION} — the Footnote Data API from your terminal (${UPGRADE_URL})`,
|
|
410
|
+
"",
|
|
411
|
+
"Usage",
|
|
412
|
+
...rows.map(([l, s]) => `${l.padEnd(width)} ${s}`),
|
|
413
|
+
"",
|
|
414
|
+
"Options",
|
|
415
|
+
" --json Raw JSON output (also the default when piped)",
|
|
416
|
+
" --key <key> API key (default: $FOOTNOTE_API_KEY)",
|
|
417
|
+
` --api-url <url> API origin (default: $FOOTNOTE_API_URL or ${DEFAULT_API_URL})`,
|
|
418
|
+
" --no-color Disable ANSI colors",
|
|
419
|
+
" -h, --help Show this help",
|
|
420
|
+
" -V, --version Show version",
|
|
421
|
+
"",
|
|
422
|
+
"Exit codes: 0 ok · 1 API/network error · 2 usage error",
|
|
423
|
+
"",
|
|
424
|
+
`Keys: create a free account at https://usefootnote.com, issue a key at ${KEYS_URL},`,
|
|
425
|
+
"then: export FOOTNOTE_API_KEY=fn_live_…",
|
|
426
|
+
].join("\n");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ----------------------------------- run ----------------------------------- */
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Runs the CLI. `io` is injectable for tests: { env, isTTY, out, err }.
|
|
433
|
+
* Network goes through globalThis.fetch so tests can swap it.
|
|
434
|
+
*/
|
|
435
|
+
export async function run(argv, io = {}) {
|
|
436
|
+
const env = io.env ?? process.env;
|
|
437
|
+
const isTTY = io.isTTY ?? process.stdout.isTTY ?? false;
|
|
438
|
+
const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
|
|
439
|
+
const err = io.err ?? ((s) => process.stderr.write(`${s}\n`));
|
|
440
|
+
const ioPair = { out, err };
|
|
441
|
+
|
|
442
|
+
const parsed = parseGlobals(argv);
|
|
443
|
+
if (parsed.usageError) return usageFail(err, parsed.usageError);
|
|
444
|
+
const { globals, words } = parsed;
|
|
445
|
+
const style = makeStyle(isTTY && !globals.noColor && !env.NO_COLOR);
|
|
446
|
+
|
|
447
|
+
if (globals.version) {
|
|
448
|
+
out(VERSION);
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
if (globals.help || words.length === 0) {
|
|
452
|
+
if (globals.help) {
|
|
453
|
+
out(helpText());
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
|
456
|
+
err(helpText());
|
|
457
|
+
return 2;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const command = parseCommand(words);
|
|
461
|
+
if (command.usageError) return usageFail(err, command.usageError);
|
|
462
|
+
|
|
463
|
+
const apiUrl = globals.apiUrl ?? env.FOOTNOTE_API_URL ?? DEFAULT_API_URL;
|
|
464
|
+
let url;
|
|
465
|
+
try {
|
|
466
|
+
url = buildUrl(apiUrl, command.path, command.params);
|
|
467
|
+
} catch {
|
|
468
|
+
return usageFail(err, `invalid API url: ${apiUrl}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const key = globals.key ?? env.FOOTNOTE_API_KEY ?? null;
|
|
472
|
+
const headers = { accept: "application/json", "user-agent": `footnote-cli/${VERSION}` };
|
|
473
|
+
if (key) headers.authorization = `Bearer ${key}`;
|
|
474
|
+
|
|
475
|
+
let res;
|
|
476
|
+
try {
|
|
477
|
+
res = await globalThis.fetch(url, { headers });
|
|
478
|
+
} catch (cause) {
|
|
479
|
+
err(`Network error: ${cause?.message ?? cause}`);
|
|
480
|
+
return 1;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const jsonMode = globals.json || !isTTY;
|
|
484
|
+
const contentType = res.headers?.get?.("content-type") ?? "";
|
|
485
|
+
|
|
486
|
+
if (!contentType.includes("application/json")) {
|
|
487
|
+
// CSV exports and other text bodies pass through untouched.
|
|
488
|
+
const text = await res.text();
|
|
489
|
+
if (res.ok) {
|
|
490
|
+
out(text.endsWith("\n") ? text.slice(0, -1) : text);
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
err(`API error (HTTP ${res.status})`);
|
|
494
|
+
if (text.trim()) err(text.trim());
|
|
495
|
+
return 1;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
let body;
|
|
499
|
+
try {
|
|
500
|
+
body = await res.json();
|
|
501
|
+
} catch {
|
|
502
|
+
err(`API error: unparseable JSON body (HTTP ${res.status})`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!res.ok) {
|
|
507
|
+
if (jsonMode) {
|
|
508
|
+
out(JSON.stringify(body, null, 2));
|
|
509
|
+
return 1;
|
|
510
|
+
}
|
|
511
|
+
return renderApiError(res.status, body, res.headers, ioPair, style);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (jsonMode) {
|
|
515
|
+
out(JSON.stringify(body, null, 2));
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const data = body?.data;
|
|
520
|
+
if (Array.isArray(data)) out(renderTable(data, style));
|
|
521
|
+
else if (data && typeof data === "object") out(renderObject(data, style));
|
|
522
|
+
else out(JSON.stringify(body, null, 2));
|
|
523
|
+
|
|
524
|
+
const metaLines = renderMeta(body?.meta, style);
|
|
525
|
+
if (metaLines.length) {
|
|
526
|
+
out("");
|
|
527
|
+
for (const line of metaLines) out(line);
|
|
528
|
+
}
|
|
529
|
+
return 0;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function usageFail(err, message) {
|
|
533
|
+
err(`Usage error: ${message}`);
|
|
534
|
+
err("Run `footnote --help` for commands and options.");
|
|
535
|
+
return 2;
|
|
536
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "footnote-data",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Footnote Data API from your terminal \u2014 WARN layoff notices, H-1B sponsorship, DOL/OSHA enforcement, severance terms, and wage comparisons from primary public records.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"warn-act",
|
|
7
|
+
"layoffs",
|
|
8
|
+
"h1b",
|
|
9
|
+
"lca",
|
|
10
|
+
"dol",
|
|
11
|
+
"osha",
|
|
12
|
+
"employment-data",
|
|
13
|
+
"labor-data",
|
|
14
|
+
"cli",
|
|
15
|
+
"footnote"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://usefootnote.com/data",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"email": "hello@usefootnote.com"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"footnote": "bin/footnote.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin/",
|
|
28
|
+
"footnote.mjs",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node test.mjs"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|