Skip to content

Commit fc64443

Browse files
committed
add private API fallback for drafts and centralize Heroku auth inheroku-api-auth
1 parent 9867578 commit fc64443

11 files changed

Lines changed: 276 additions & 63 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ heroku devcenter:pull https://devcenter.heroku.com/articles/article-slug
4848

4949
This writes `article-slug.md` in the current directory: YAML front matter (`title`, `id`) then a blank line, then markdown body. You may edit the title and content, but **do not change the article `id`**.
5050

51+
`pull` first requests the public `/articles/<slug>.json` endpoint. If that fails and you have a plain **`~/.netrc`** from **`heroku login`**, it **retries the same URL with Heroku API credentials**, then **`GET /api/v1/private/articles/<slug>.json`** (the same private API as `push`) so drafts and other non-public articles can load when your Dev Center account is authorized. Run **`heroku devcenter:pull <slug> --debug`** to print status and full JSON bodies for each attempt.
52+
5153
Use `--force` (`-f`) to overwrite an existing file without prompting.
5254

5355
### Preview a local article

slowdb.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
id: 2360
2+
title: SlowDB
3+
4+
Test

src/commands/devcenter/pull.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import {stringify as stringifyYaml} from 'yaml'
66

77
import {formatArticleNotFoundMessage} from '../../lib/article-not-found.js'
88
import {DevcenterClient} from '../../lib/devcenter-client.js'
9-
import {articleApiPath, mdFilePath, slugFromArticleUrl} from '../../lib/paths.js'
9+
import {getHerokuApiToken} from '../../lib/heroku-api-auth.js'
10+
import {
11+
articleApiPath,
12+
getDevcenterBaseUrl,
13+
mdFilePath,
14+
privateArticleShowPath,
15+
slugFromArticleUrl,
16+
} from '../../lib/paths.js'
1017

1118
type ArticleJson = {
1219
content: string
@@ -24,6 +31,10 @@ export default class Pull extends Command {
2431
}
2532
static description = 'save a local copy of a Dev Center article'
2633
static flags = {
34+
debug: Flags.boolean({
35+
description:
36+
'log HTTP status and response shape for public, authenticated public, and private API article fetch',
37+
}),
2738
force: Flags.boolean({
2839
char: 'f',
2940
description: 'overwrite an existing local file without prompting',
@@ -41,9 +52,55 @@ export default class Pull extends Command {
4152
)
4253
}
4354

55+
const dbg = (message: string) => {
56+
if (flags.debug) {
57+
process.stdout.write(`devcenter: ${message}\n`)
58+
}
59+
}
60+
4461
const client = new DevcenterClient()
45-
const {body, ok} = await client.getJson<ArticleJson>(articleApiPath(slug))
46-
const articleOk = ok && body?.slug === slug
62+
const path = articleApiPath(slug)
63+
dbg(`baseUrl=${getDevcenterBaseUrl()} path=${path} expectedSlug=${slug}`)
64+
65+
let {body, ok, status} = await client.getJson<ArticleJson>(path)
66+
let articleOk = ok && body?.slug === slug
67+
logArticleJsonFetch(dbg, {
68+
expectedSlug: slug, label: 'no auth', path, res: {body, ok, status},
69+
})
70+
71+
let token: string | undefined
72+
try {
73+
token = getHerokuApiToken()
74+
} catch (error) {
75+
const msg = error instanceof Error ? error.message : String(error)
76+
dbg(`Heroku ~/.netrc token unavailable: ${msg}`)
77+
}
78+
79+
if (!articleOk && token) {
80+
dbg('retrying GET with Heroku ~/.netrc token (public JSON)')
81+
const authed = await client.getJson<ArticleJson>(path, undefined, {token})
82+
body = authed.body
83+
ok = authed.ok
84+
status = authed.status
85+
articleOk = ok && body?.slug === slug
86+
logArticleJsonFetch(dbg, {
87+
expectedSlug: slug, label: 'authenticated public', path, res: authed,
88+
})
89+
}
90+
91+
if (!articleOk && token) {
92+
const privatePath = privateArticleShowPath(slug)
93+
dbg(`retrying GET private API ${privatePath}`)
94+
const priv = await client.getJson<ArticleJson>(privatePath, undefined, {token})
95+
body = priv.body
96+
ok = priv.ok
97+
status = priv.status
98+
articleOk = ok && body?.slug === slug
99+
logArticleJsonFetch(dbg, {
100+
expectedSlug: slug, label: 'private API', path: privatePath, res: priv,
101+
})
102+
}
103+
47104
if (!articleOk) {
48105
const msg = await formatArticleNotFoundMessage(client, slug)
49106
this.error(msg, {exit: 1})
@@ -69,3 +126,32 @@ export default class Pull extends Command {
69126
this.log(`"${metadata.title}" article saved as ${filePath}`)
70127
}
71128
}
129+
130+
function logArticleJsonFetch(
131+
dbg: (m: string) => void,
132+
opts: {
133+
expectedSlug: string
134+
label: string
135+
path: string
136+
res: {body: ArticleJson; ok: boolean; status: number}
137+
},
138+
): void {
139+
const {expectedSlug, label, path, res} = opts
140+
const {body, ok, status} = res
141+
const slugPart = body?.slug === undefined ? '(missing)' : JSON.stringify(body.slug)
142+
const titlePart
143+
= body?.title === undefined ? '(missing)' : JSON.stringify(String(body.title).slice(0, 80))
144+
dbg(`GET ${path} (${label}): status=${status} ok=${ok} body.slug=${slugPart} title=${titlePart}`)
145+
dbg(`slugMatch=${String(body?.slug === expectedSlug)} (want ${JSON.stringify(expectedSlug)})`)
146+
if (body && typeof body === 'object') {
147+
dbg(`response keys: ${Object.keys(body as object).sort().join(', ')}`)
148+
dbg('response body (full JSON):')
149+
try {
150+
for (const line of JSON.stringify(body, undefined, 2).split('\n')) {
151+
dbg(` ${line}`)
152+
}
153+
} catch {
154+
dbg(` (could not stringify body: ${String(body)})`)
155+
}
156+
}
157+
}

src/commands/devcenter/push.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {stringify as stringifyYaml} from 'yaml'
55

66
import {ArticleFile} from '../../lib/article-file.js'
77
import {DevcenterClient} from '../../lib/devcenter-client.js'
8-
import {getHerokuNetrcToken} from '../../lib/netrc-token.js'
8+
import {getHerokuApiToken} from '../../lib/heroku-api-auth.js'
99
import {mdFilePath} from '../../lib/paths.js'
1010

1111
function hasValidationErrors(body: unknown): boolean {
@@ -59,7 +59,7 @@ export default class Push extends Command {
5959

6060
let token: string
6161
try {
62-
token = getHerokuNetrcToken()
62+
token = getHerokuApiToken()
6363
} catch (error) {
6464
const message = error instanceof Error ? error.message : String(error)
6565
this.error(message, {exit: 1})

src/lib/devcenter-client.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as http from 'node:http'
22
import * as https from 'node:https'
33
import {URL} from 'node:url'
44

5+
import {basicAuthHeaders} from './heroku-api-auth.js'
56
import {
67
brokenLinkChecksPath,
78
getDevcenterBaseUrl,
@@ -65,13 +66,12 @@ export class DevcenterClient {
6566
token: string,
6667
params: Record<string, string>,
6768
): Promise<{body: unknown; ok: boolean; status: number;}> {
68-
const auth = Buffer.from(token).toString('base64')
6969
const body = new URLSearchParams(params).toString()
7070
const res = await nodeRequest(method, this.url(path), {
7171
body,
7272
headers: {
7373
...DEFAULT_HEADERS,
74-
Authorization: `Basic ${auth}`,
74+
...basicAuthHeaders(token),
7575
'Content-Length': String(Buffer.byteLength(body)),
7676
'Content-Type': 'application/x-www-form-urlencoded',
7777
},
@@ -92,15 +92,24 @@ export class DevcenterClient {
9292
return this.authForm('POST', brokenLinkChecksPath(), token, {content})
9393
}
9494

95-
async getJson<T>(path: string, query?: Record<string, string>): Promise<{body: T; ok: boolean; status: number;}> {
95+
async getJson<T>(
96+
path: string,
97+
query?: Record<string, string>,
98+
options?: {token?: string},
99+
): Promise<{body: T; ok: boolean; status: number;}> {
96100
const u = new URL(this.url(path))
97101
if (query) {
98102
for (const [k, v] of Object.entries(query)) {
99103
u.searchParams.set(k, v)
100104
}
101105
}
102106

103-
const res = await nodeRequest('GET', u.toString(), {headers: {...DEFAULT_HEADERS}})
107+
const headers: Record<string, string> = {...DEFAULT_HEADERS}
108+
if (options?.token) {
109+
Object.assign(headers, basicAuthHeaders(options.token))
110+
}
111+
112+
const res = await nodeRequest('GET', u.toString(), {headers})
104113
let parsed: T
105114
try {
106115
parsed = JSON.parse(res.body || '{}') as T
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import {homedir} from 'node:os'
44
import {join} from 'node:path'
55

66
/**
7-
* Returns the Heroku API token from ~/.netrc (same source as the legacy Ruby CLI).
8-
* Encrypted ~/.netrc.gpg is not supported; use a plain netrc or `heroku login`.
7+
* Heroku API token from `~/.netrc` (`api.heroku.com`), same source as `heroku login`.
8+
* Encrypted `~/.netrc.gpg` is not supported.
99
*/
10-
export function getHerokuNetrcToken(): string {
10+
export function getHerokuApiToken(): string {
1111
const home = homedir()
1212
const gpgPath = join(home, '.netrc.gpg')
1313
const plainPath = join(home, '.netrc')
@@ -28,3 +28,13 @@ export function getHerokuNetrcToken(): string {
2828

2929
return token
3030
}
31+
32+
/** `Authorization` header value Dev Center private APIs expect (matches legacy CLI behavior). */
33+
export function basicAuthHeaderValue(token: string): string {
34+
const encoded = Buffer.from(token).toString('base64')
35+
return `Basic ${encoded}`
36+
}
37+
38+
export function basicAuthHeaders(token: string): Record<string, string> {
39+
return {Authorization: basicAuthHeaderValue(token)}
40+
}

src/lib/paths.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export function updateArticlePath(id: number | string): string {
2424
return `/api/v1/private/articles/${id}.json`
2525
}
2626

27+
/** Private CMS show by numeric id or slug (same path segment as `update`). */
28+
export function privateArticleShowPath(slugOrId: string): string {
29+
return `/api/v1/private/articles/${encodeURIComponent(slugOrId)}.json`
30+
}
31+
2732
export function brokenLinkChecksPath(): string {
2833
return '/api/v1/private/broken-link-checks.json'
2934
}

test/commands/pull.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@ import {join} from 'node:path'
88

99
import Pull from '../../src/commands/devcenter/pull.js'
1010
import {runCommand} from '../helpers/run-command.js'
11+
import {
12+
applyHomeEnv, type HomeEnvSnapshot, setHomeDirForTests, snapshotHomeEnv,
13+
} from '../helpers/test-home-env.js'
1114

1215
describe('devcenter:pull', function () {
1316
let workDir: string
17+
let homeEnv: HomeEnvSnapshot
18+
let noNetrcHome: string
1419
let previousArticleCwd: string | undefined
1520
let previousTestConfirm: string | undefined
1621

1722
beforeEach(function () {
23+
homeEnv = snapshotHomeEnv()
24+
noNetrcHome = mkdtempSync(join(tmpdir(), 'devcenter-pull-home-'))
25+
setHomeDirForTests(noNetrcHome)
1826
workDir = mkdtempSync(join(tmpdir(), 'devcenter-pull-'))
1927
previousArticleCwd = process.env.DEVCENTER_CLI_CWD
2028
previousTestConfirm = process.env.DEVCENTER_CLI_TEST_CONFIRM
@@ -30,13 +38,15 @@ describe('devcenter:pull', function () {
3038
}
3139

3240
nock.cleanAll()
41+
applyHomeEnv(homeEnv)
3342
if (previousArticleCwd === undefined) {
3443
delete process.env.DEVCENTER_CLI_CWD
3544
} else {
3645
process.env.DEVCENTER_CLI_CWD = previousArticleCwd
3746
}
3847

3948
rmSync(workDir, {recursive: true})
49+
rmSync(noNetrcHome, {recursive: true})
4050
})
4151

4252
it('writes a local markdown file from the Dev Center API', async function () {
@@ -73,6 +83,64 @@ describe('devcenter:pull', function () {
7383
expect(error?.message).to.contain('Please provide an article slug')
7484
})
7585

86+
it('retries with netrc auth when the public JSON request fails', async function () {
87+
const token = 'fake-pull-token'
88+
writeFileSync(
89+
join(noNetrcHome, '.netrc'),
90+
`machine api.heroku.com
91+
login t@t.com
92+
password ${token}
93+
`,
94+
'utf8',
95+
)
96+
97+
nock('https://devcenter.heroku.com').get('/articles/draftish.json').reply(404, {})
98+
nock('https://devcenter.heroku.com', {
99+
reqheaders: {authorization: `Basic ${Buffer.from(token).toString('base64')}`},
100+
})
101+
.get('/articles/draftish.json')
102+
.reply(200, {
103+
content: 'Draft **body**.',
104+
id: 99,
105+
slug: 'draftish',
106+
title: 'Draft Title',
107+
})
108+
109+
const {error} = await runCommand(Pull, ['draftish', '--force'])
110+
expect(error).to.equal(undefined)
111+
expect(readFileSync(join(workDir, 'draftish.md'), 'utf8')).to.contain('Draft **body**.')
112+
})
113+
114+
it('falls back to private API when public JSON stays unavailable', async function () {
115+
const token = 'fake-pull-token'
116+
const auth = {authorization: `Basic ${Buffer.from(token).toString('base64')}`}
117+
writeFileSync(
118+
join(noNetrcHome, '.netrc'),
119+
`machine api.heroku.com
120+
login t@t.com
121+
password ${token}
122+
`,
123+
'utf8',
124+
)
125+
126+
nock('https://devcenter.heroku.com').get('/articles/private-only.json').reply(401, {error: 'Authentication required'})
127+
nock('https://devcenter.heroku.com', {reqheaders: auth})
128+
.get('/articles/private-only.json')
129+
.reply(401, {error: 'Authentication required'})
130+
nock('https://devcenter.heroku.com', {reqheaders: auth})
131+
.get('/api/v1/private/articles/private-only.json')
132+
.reply(200, {
133+
content: 'From **private** API.',
134+
id: 42,
135+
slug: 'private-only',
136+
title: 'Private Only Title',
137+
})
138+
139+
const {error} = await runCommand(Pull, ['private-only', '--force'])
140+
expect(error).to.equal(undefined)
141+
expect(readFileSync(join(workDir, 'private-only.md'), 'utf8')).to.contain('From **private** API.')
142+
})
143+
76144
it('does not overwrite when user declines the prompt', async function () {
77145
writeFileSync(join(workDir, 'keep.md'), 'title: Old\nid: 1\n\nold', 'utf8')
78146
nock('https://devcenter.heroku.com')

test/unit/devcenter-client.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ describe('DevcenterClient', function () {
2727
expect(res.notFound).to.equal(true)
2828
})
2929

30+
it('getJson sends Authorization when token is passed', async function () {
31+
nock('https://devcenter.heroku.com', {
32+
reqheaders: {authorization: `Basic ${Buffer.from('secret').toString('base64')}`},
33+
})
34+
.get('/articles/z.json')
35+
.reply(200, {
36+
content: 'x', id: 1, slug: 'z', title: 'Z',
37+
})
38+
39+
const client = new DevcenterClient()
40+
const res = await client.getJson('/articles/z.json', undefined, {token: 'secret'})
41+
expect(res.ok).to.equal(true)
42+
expect((res.body as {slug?: string}).slug).to.equal('z')
43+
})
44+
3045
it('getJson parses query params and tolerates invalid JSON body', async function () {
3146
nock('https://devcenter.heroku.com')
3247
.get('/articles/x.json')

0 commit comments

Comments
 (0)