docugate 0.2.8 → 0.3.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 +200 -57
- package/dist/api.d.ts +44 -0
- package/dist/api.js +11 -5
- package/dist/args.d.ts +29 -0
- package/dist/args.js +121 -0
- package/dist/auth.d.ts +21 -0
- package/dist/banner.d.ts +13 -0
- package/dist/banner.js +62 -0
- package/dist/check.d.ts +22 -0
- package/dist/check.js +4 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +214 -165
- package/dist/commands.d.ts +35 -0
- package/dist/commands.js +293 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +43 -3
- package/dist/developers.d.ts +35 -0
- package/dist/developers.js +439 -0
- package/dist/init.d.ts +130 -0
- package/dist/init.js +102 -36
- package/dist/keys/core.d.ts +108 -0
- package/dist/keys/core.js +142 -0
- package/dist/keys/express.d.ts +21 -0
- package/dist/keys/express.js +27 -0
- package/dist/keys/fastify.d.ts +21 -0
- package/dist/keys/fastify.js +21 -0
- package/dist/keys/format.d.ts +11 -0
- package/dist/keys/format.js +46 -0
- package/dist/keys/hono.d.ts +19 -0
- package/dist/keys/hono.js +21 -0
- package/dist/keys/index.d.ts +13 -0
- package/dist/keys/index.js +12 -0
- package/dist/logo-pixels.d.ts +4 -0
- package/dist/logo-pixels.js +35 -0
- package/dist/logo.d.ts +19 -0
- package/dist/logo.js +68 -0
- package/dist/menu.d.ts +16 -0
- package/dist/menu.js +157 -0
- package/dist/openapi.d.ts +38 -0
- package/dist/openapi.js +2 -1
- package/dist/rules.d.ts +69 -0
- package/dist/rules.js +2 -1
- package/dist/select.d.ts +75 -0
- package/dist/select.js +215 -0
- package/dist/session.d.ts +46 -0
- package/dist/session.js +54 -0
- package/dist/spinner.d.ts +12 -0
- package/dist/spinner.js +12 -12
- package/dist/status.d.ts +46 -0
- package/dist/status.js +166 -0
- package/dist/sync/ai.d.ts +44 -0
- package/dist/sync/ai.js +147 -0
- package/dist/sync/apply.d.ts +25 -0
- package/dist/sync/apply.js +104 -0
- package/dist/sync/changes.d.ts +58 -0
- package/dist/sync/changes.js +167 -0
- package/dist/sync/command.d.ts +12 -0
- package/dist/sync/command.js +236 -0
- package/dist/sync/guard.d.ts +15 -0
- package/dist/sync/guard.js +21 -0
- package/dist/sync/hook.d.ts +10 -0
- package/dist/sync/hook.js +62 -0
- package/dist/sync/impact.d.ts +23 -0
- package/dist/sync/impact.js +93 -0
- package/dist/sync/pr.d.ts +37 -0
- package/dist/sync/pr.js +106 -0
- package/dist/sync/run.d.ts +50 -0
- package/dist/sync/run.js +124 -0
- package/dist/sync/state.d.ts +8 -0
- package/dist/sync/state.js +22 -0
- package/dist/sync/workflow.d.ts +20 -0
- package/dist/sync/workflow.js +84 -0
- package/dist/text.d.ts +11 -0
- package/dist/text.js +16 -0
- package/dist/tour-init.d.ts +61 -0
- package/dist/tour-install.d.ts +38 -0
- package/dist/tour-install.js +58 -0
- package/dist/tour-server.d.ts +21 -0
- package/dist/tour.d.ts +60 -0
- package/dist/ui.d.ts +29 -0
- package/dist/ui.js +63 -0
- package/package.json +67 -47
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join, relative } from 'node:path';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { apiFetch, BASE_URL, createSpace, getSession, getSpaces } from './api.js';
|
|
6
|
+
import { login } from './auth.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { choose, defaultGetRemote, makeDefaultIO, saveSetting } from './init.js';
|
|
9
|
+
import { bold, dim, gold, green, plural, yellow } from './ui.js';
|
|
10
|
+
// `docugate developers`: a developer space in one sitting. It finds the
|
|
11
|
+
// OpenAPI file, writes the first pages where there are none, creates or links
|
|
12
|
+
// a public space for them, and on Pro gives it an address, decides how the API
|
|
13
|
+
// console sends requests, switches on API keys and hands over the verify
|
|
14
|
+
// secret with the lines the backend needs. Every step says what it is about to
|
|
15
|
+
// do, and nothing that already exists is overwritten.
|
|
16
|
+
const SITE = 'https://www.trydocugate.site';
|
|
17
|
+
/** Folders never worth walking for a spec. */
|
|
18
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', 'vendor', '.venv', 'venv', 'target']);
|
|
19
|
+
/** File names that are, by convention, an API description. */
|
|
20
|
+
const SPEC_FILE = /^(openapi|swagger)(\.[\w-]+)?\.(json|ya?ml)$/i;
|
|
21
|
+
/**
|
|
22
|
+
* OpenAPI files in a repository, nearest the root first. Shallow on purpose: a
|
|
23
|
+
* spec lives near the top, and a walk through a large monorepo should not make
|
|
24
|
+
* the setup feel stuck.
|
|
25
|
+
*/
|
|
26
|
+
export function findSpecs(root, maxDepth = 4) {
|
|
27
|
+
const found = [];
|
|
28
|
+
const walk = (dir, depth) => {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
if (depth < maxDepth && !SKIP.has(entry.name) && !entry.name.startsWith('.'))
|
|
39
|
+
walk(join(dir, entry.name), depth + 1);
|
|
40
|
+
}
|
|
41
|
+
else if (SPEC_FILE.test(entry.name)) {
|
|
42
|
+
found.push(relative(root, join(dir, entry.name)).replace(/\\/g, '/'));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(root, 0);
|
|
47
|
+
return found.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
|
|
48
|
+
}
|
|
49
|
+
/** What a spec holds, enough to say so in a line. Null when it cannot be read. */
|
|
50
|
+
export function readSpec(path) {
|
|
51
|
+
try {
|
|
52
|
+
const text = readFileSync(path, 'utf8');
|
|
53
|
+
const doc = (/\.json$/i.test(path) ? JSON.parse(text) : parseYaml(text));
|
|
54
|
+
if (!doc || typeof doc !== 'object')
|
|
55
|
+
return null;
|
|
56
|
+
const paths = (doc.paths ?? {});
|
|
57
|
+
const methods = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
|
|
58
|
+
let operations = 0;
|
|
59
|
+
const tags = new Set();
|
|
60
|
+
for (const item of Object.values(paths)) {
|
|
61
|
+
if (!item || typeof item !== 'object')
|
|
62
|
+
continue;
|
|
63
|
+
for (const [method, op] of Object.entries(item)) {
|
|
64
|
+
if (!methods.has(method))
|
|
65
|
+
continue;
|
|
66
|
+
operations++;
|
|
67
|
+
const opTags = op?.tags;
|
|
68
|
+
if (Array.isArray(opTags))
|
|
69
|
+
for (const t of opTags)
|
|
70
|
+
if (typeof t === 'string')
|
|
71
|
+
tags.add(t);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const info = doc.info;
|
|
75
|
+
return {
|
|
76
|
+
title: typeof info?.title === 'string' ? info.title : null,
|
|
77
|
+
operations,
|
|
78
|
+
tags: [...tags],
|
|
79
|
+
swagger2: typeof doc.swagger === 'string',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Where the server will find a spec without being told: openapi.json/.yaml/.yml at the root or in the docs folder. */
|
|
87
|
+
export function specIsFoundByDefault(spec, docsDir) {
|
|
88
|
+
const name = /^openapi\.(json|ya?ml)$/i;
|
|
89
|
+
const dir = docsDir.replace(/^\/|\/$/g, '');
|
|
90
|
+
if (!spec.includes('/'))
|
|
91
|
+
return name.test(spec);
|
|
92
|
+
return spec.startsWith(`${dir}/`) && !spec.slice(dir.length + 1).includes('/') && name.test(spec.slice(dir.length + 1));
|
|
93
|
+
}
|
|
94
|
+
/** The web framework a Node backend uses, from its package.json. */
|
|
95
|
+
export function detectFramework(root) {
|
|
96
|
+
try {
|
|
97
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
98
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
99
|
+
if (deps.hono)
|
|
100
|
+
return 'hono';
|
|
101
|
+
if (deps.fastify)
|
|
102
|
+
return 'fastify';
|
|
103
|
+
if (deps.express)
|
|
104
|
+
return 'express';
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* not a Node project, or no package.json */
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/** The lines that make a backend check keys, for the framework it uses. */
|
|
112
|
+
export function middlewareSnippet(framework) {
|
|
113
|
+
if (framework === 'hono') {
|
|
114
|
+
return [
|
|
115
|
+
"import { requireKey } from 'docugate/hono'",
|
|
116
|
+
'',
|
|
117
|
+
"app.use('/api/*', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))",
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
if (framework === 'fastify') {
|
|
121
|
+
return [
|
|
122
|
+
"import { requireKey } from 'docugate/fastify'",
|
|
123
|
+
'',
|
|
124
|
+
"app.addHook('onRequest', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))",
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
if (framework === 'express') {
|
|
128
|
+
return [
|
|
129
|
+
"import { requireKey } from 'docugate/express'",
|
|
130
|
+
'',
|
|
131
|
+
"app.use('/api', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))",
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
return [
|
|
135
|
+
"import { verifyKey } from 'docugate/keys'",
|
|
136
|
+
'',
|
|
137
|
+
'const verdict = await verifyKey(key, { secret: process.env.DOCUGATE_VERIFY_SECRET! })',
|
|
138
|
+
'if (!verdict.valid) /* answer 401 */',
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
/** A scope name the server accepts, from an OpenAPI tag: `Shipments` → `shipments:read`. */
|
|
142
|
+
export function scopeFromTag(tag) {
|
|
143
|
+
const base = tag
|
|
144
|
+
.toLowerCase()
|
|
145
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
146
|
+
.replace(/^-|-$/g, '')
|
|
147
|
+
.slice(0, 26);
|
|
148
|
+
return base ? `${base}:read` : null;
|
|
149
|
+
}
|
|
150
|
+
/** The first pages of a developer space. Only the ones missing are written. */
|
|
151
|
+
export function starterPages(title, spec) {
|
|
152
|
+
const api = spec?.title ?? title;
|
|
153
|
+
return [
|
|
154
|
+
{
|
|
155
|
+
file: 'index.md',
|
|
156
|
+
body: `# ${api}\n\n` +
|
|
157
|
+
`Everything you need to build on the ${api}.\n\n` +
|
|
158
|
+
`- [Quickstart](quickstart.md): your first request, in a few minutes.\n` +
|
|
159
|
+
`- [Authentication](authentication.md): how to get a key and send it.\n` +
|
|
160
|
+
`- [Errors](errors.md): what each error means, and what to do about it.\n` +
|
|
161
|
+
(spec ? `\nThe full reference${spec.operations ? ` (${plural(spec.operations, 'endpoint')})` : ''} is in the sidebar, with a console to try each one.\n` : ''),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
file: 'quickstart.md',
|
|
165
|
+
body: `# Quickstart\n\n` +
|
|
166
|
+
`1. Get an API key: see [Authentication](authentication.md).\n` +
|
|
167
|
+
`2. Send your first request:\n\n` +
|
|
168
|
+
'```bash\ncurl https://api.example.com/v1/... \\\n -H "Authorization: Bearer $API_KEY"\n```\n\n' +
|
|
169
|
+
`3. Read the response, then try the same call from the API console in the sidebar.\n\n` +
|
|
170
|
+
`> Replace the example address and path with a real one from your API.\n`,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
file: 'authentication.md',
|
|
174
|
+
body: `# Authentication\n\n` +
|
|
175
|
+
`Every request carries an API key in the \`Authorization\` header:\n\n` +
|
|
176
|
+
'```http\nAuthorization: Bearer <your key>\n```\n\n' +
|
|
177
|
+
`Generate a key from the **API keys** panel on these docs. A key is shown once, so copy it somewhere safe. ` +
|
|
178
|
+
`Keys can be revoked at any time, and a revoked key stops working on its next request.\n`,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
file: 'errors.md',
|
|
182
|
+
body: `# Errors\n\n` +
|
|
183
|
+
`| Status | Meaning | What to do |\n| --- | --- | --- |\n` +
|
|
184
|
+
`| 400 | The request is malformed | Check the body against the reference |\n` +
|
|
185
|
+
`| 401 | The key is missing, unknown or revoked | Send a valid key; generate a new one if needed |\n` +
|
|
186
|
+
`| 403 | The key may not do this | Ask for the scope this call needs |\n` +
|
|
187
|
+
`| 404 | Nothing at that address | Check the path and the id |\n` +
|
|
188
|
+
`| 429 | Too many requests | Wait, then retry with backoff |\n` +
|
|
189
|
+
`| 5xx | Our side failed | Retry later; contact us if it persists |\n`,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
/** Whether a folder in the repository is on its remote branch yet, so a space can read it. */
|
|
194
|
+
function isPushed(root, folder) {
|
|
195
|
+
try {
|
|
196
|
+
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
197
|
+
const listed = execFileSync('git', ['ls-tree', '-r', '--name-only', `origin/${branch}`, '--', folder], {
|
|
198
|
+
cwd: root,
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
201
|
+
});
|
|
202
|
+
return listed.trim().length > 0;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function pushFolder(root, paths) {
|
|
209
|
+
const git = (...args) => execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
210
|
+
git('add', '--', ...paths);
|
|
211
|
+
git('commit', '-m', 'docs: start the developer docs for DocuGate', '--', ...paths);
|
|
212
|
+
git('push');
|
|
213
|
+
}
|
|
214
|
+
async function yesNo(io, question, fallback = true) {
|
|
215
|
+
const answer = (await io.ask(` ${question} ${dim(fallback ? '[Y/n]' : '[y/N]')}`, fallback ? 'y' : 'n')).trim().toLowerCase();
|
|
216
|
+
return answer.startsWith('y');
|
|
217
|
+
}
|
|
218
|
+
const step = (io, n, title) => {
|
|
219
|
+
io.print('');
|
|
220
|
+
io.print(` ${gold(String(n))} ${bold(title)}`);
|
|
221
|
+
};
|
|
222
|
+
export async function developersFlow(root, options = {}) {
|
|
223
|
+
const io = options.io ?? makeDefaultIO();
|
|
224
|
+
const yes = options.yes ?? false;
|
|
225
|
+
const ask = (q, d) => (yes ? Promise.resolve(d) : io.ask(` ${q} ${dim(`[${d}]`)}`, d));
|
|
226
|
+
const confirm = (q, d = true) => (yes ? Promise.resolve(d) : yesNo(io, q, d));
|
|
227
|
+
io.print('');
|
|
228
|
+
io.print(` ${bold('Set up a developer space')}`);
|
|
229
|
+
io.print(` ${dim('API reference, guides and keys for the developers who build on your API.')}`);
|
|
230
|
+
// ── 1. Who ───────────────────────────────────────────────────────────────
|
|
231
|
+
step(io, 1, 'Your DocuGate account');
|
|
232
|
+
let user = (await getSession().catch(() => ({ user: null }))).user;
|
|
233
|
+
if (!user) {
|
|
234
|
+
io.print(' Not signed in. Opening your browser to sign in with GitHub…');
|
|
235
|
+
await login(BASE_URL);
|
|
236
|
+
user = (await getSession()).user;
|
|
237
|
+
}
|
|
238
|
+
if (!user)
|
|
239
|
+
throw new Error('Sign-in did not complete. Run docugate login, then try again.');
|
|
240
|
+
const paid = user.plan === 'pro' || user.plan === 'enterprise';
|
|
241
|
+
io.print(` ${green('✓')} @${user.githubLogin} ${dim(`· ${paid ? 'Pro' : 'Free plan'}`)}`);
|
|
242
|
+
// ── 2. Where ─────────────────────────────────────────────────────────────
|
|
243
|
+
step(io, 2, 'The repository');
|
|
244
|
+
const repo = defaultGetRemote(root);
|
|
245
|
+
if (!repo) {
|
|
246
|
+
io.print(` ${yellow('!')} This folder has no GitHub remote. A space reads its pages from GitHub,`);
|
|
247
|
+
io.print(' so push this repository to GitHub first, then run docugate developers again.');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
io.print(` ${green('✓')} ${repo}`);
|
|
251
|
+
// ── 3. The API description ───────────────────────────────────────────────
|
|
252
|
+
step(io, 3, 'The OpenAPI file');
|
|
253
|
+
const specs = findSpecs(root);
|
|
254
|
+
let spec = null;
|
|
255
|
+
if (specs.length === 1)
|
|
256
|
+
spec = specs[0];
|
|
257
|
+
else if (specs.length > 1) {
|
|
258
|
+
const n = yes ? 1 : await choose(io, 'Which file describes the API?', [...specs, 'None of these']);
|
|
259
|
+
spec = n <= specs.length ? specs[n - 1] : null;
|
|
260
|
+
}
|
|
261
|
+
const summary = spec ? readSpec(join(root, spec)) : null;
|
|
262
|
+
if (spec && summary?.swagger2) {
|
|
263
|
+
io.print(` ${yellow('!')} ${spec} is Swagger 2. DocuGate reads OpenAPI 3: convert it first, for example with`);
|
|
264
|
+
io.print(' https://converter.swagger.io, then run this again.');
|
|
265
|
+
spec = null;
|
|
266
|
+
}
|
|
267
|
+
else if (spec) {
|
|
268
|
+
io.print(` ${green('✓')} ${spec}${summary ? dim(` · ${plural(summary.operations, 'endpoint')}${summary.title ? ` · ${summary.title}` : ''}`) : ''}`);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
io.print(` ${yellow('!')} No OpenAPI file found. The space can still hold your guides.`);
|
|
272
|
+
io.print(` To add the reference later: ${gold('docugate openapi --from <file or url>')}`);
|
|
273
|
+
}
|
|
274
|
+
// ── 4. The pages ─────────────────────────────────────────────────────────
|
|
275
|
+
step(io, 4, 'The pages');
|
|
276
|
+
const folder = (options.dir ?? (await ask('Folder for the developer docs', 'docs/developers'))).replace(/^\.?\/|\/$/g, '');
|
|
277
|
+
const base = join(root, folder);
|
|
278
|
+
mkdirSync(base, { recursive: true });
|
|
279
|
+
const written = [];
|
|
280
|
+
for (const page of starterPages(basename(root), summary)) {
|
|
281
|
+
const path = join(base, page.file);
|
|
282
|
+
if (existsSync(path))
|
|
283
|
+
continue;
|
|
284
|
+
writeFileSync(path, page.body);
|
|
285
|
+
written.push(`${folder}/${page.file}`);
|
|
286
|
+
}
|
|
287
|
+
if (written.length)
|
|
288
|
+
io.print(` ${green('✓')} Wrote ${written.join(', ')} ${dim('(edit them freely)')}`);
|
|
289
|
+
else
|
|
290
|
+
io.print(` ${green('✓')} ${folder}/ already has its pages; nothing was changed.`);
|
|
291
|
+
// The server finds openapi.json at the root or in the docs folder; anywhere
|
|
292
|
+
// else, docugate.json has to say where.
|
|
293
|
+
if (spec && !specIsFoundByDefault(spec, folder)) {
|
|
294
|
+
const api = (loadConfig(root).config.api ?? {});
|
|
295
|
+
saveSetting(root, 'api', { ...api, spec });
|
|
296
|
+
io.print(` ${green('✓')} Saved api.spec = ${spec} in docugate.json`);
|
|
297
|
+
written.push('docugate.json');
|
|
298
|
+
}
|
|
299
|
+
// A space reads GitHub, not this folder: what was written has to be pushed.
|
|
300
|
+
if (!isPushed(root, folder) || written.length) {
|
|
301
|
+
const paths = [folder, ...(written.includes('docugate.json') ? ['docugate.json'] : [])];
|
|
302
|
+
if (await confirm(`Commit and push ${paths.join(' and ')} so DocuGate can read them?`)) {
|
|
303
|
+
try {
|
|
304
|
+
pushFolder(root, paths);
|
|
305
|
+
io.print(` ${green('✓')} Pushed`);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
io.print(` ${yellow('!')} Could not push: ${error.message.split('\n')[0]}`);
|
|
309
|
+
io.print(` Push ${paths.join(' and ')} yourself; the space shows them from the next push.`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
io.print(` Push ${paths.join(' and ')} when ready; the space shows them from that push.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// ── 5. The space ─────────────────────────────────────────────────────────
|
|
317
|
+
step(io, 5, 'The space');
|
|
318
|
+
const { spaces } = await getSpaces();
|
|
319
|
+
const reads = (sp) => sp.sources?.some((s) => s.repo.toLowerCase() === repo.toLowerCase());
|
|
320
|
+
const existing = spaces.find(reads);
|
|
321
|
+
let space;
|
|
322
|
+
if (existing && (yes || (await confirm(`Use your space "${existing.name}" (/${existing.owner}/${existing.slug})?`)))) {
|
|
323
|
+
space = existing;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const name = await ask('Name of the developer space', `${(summary?.title ?? repo.split('/')[1])} Developers`.replace(/\s+API Developers$/, ' API'));
|
|
327
|
+
space = await createSpace(repo, name, folder, 'public');
|
|
328
|
+
io.print(` ${green('✓')} Created "${space.name}" ${dim('· public, so developers can read it before they sign up')}`);
|
|
329
|
+
}
|
|
330
|
+
saveSetting(root, 'space', `${space.owner}/${space.slug}`);
|
|
331
|
+
let address = `${SITE}/${space.owner}/${space.slug}`;
|
|
332
|
+
if (!paid) {
|
|
333
|
+
io.print('');
|
|
334
|
+
io.print(` ${bold('Done.')} Your developer docs: ${gold(address)}`);
|
|
335
|
+
io.print('');
|
|
336
|
+
io.print(` ${dim('On Pro, this same command also gives the space its own address, lets developers')}`);
|
|
337
|
+
io.print(` ${dim('generate API keys on the page, and sends the API console through DocuGate.')}`);
|
|
338
|
+
io.print(` ${dim(`Upgrade: ${SITE}/dashboard/upgrade, then run docugate developers again.`)}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// ── 6. Address ───────────────────────────────────────────────────────────
|
|
342
|
+
step(io, 6, 'An address of its own');
|
|
343
|
+
const label = (await ask('Subdomain', repo.split('/')[0].toLowerCase().replace(/[^a-z0-9-]/g, '-'))).toLowerCase();
|
|
344
|
+
try {
|
|
345
|
+
const check = (await (await apiFetch(`/api/domains/${space.id}/subdomain/check?label=${encodeURIComponent(label)}`)).json());
|
|
346
|
+
if (!check.available) {
|
|
347
|
+
io.print(` ${yellow('!')} ${label} is ${check.reason === 'label_taken' ? 'taken' : 'not a name we can use'}. Pick one in the space's settings later.`);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const res = await apiFetch(`/api/domains/${space.id}/subdomain`, { method: 'PUT', body: JSON.stringify({ label }) });
|
|
351
|
+
const { subdomain } = (await res.json());
|
|
352
|
+
if (subdomain) {
|
|
353
|
+
address = `https://${subdomain.host}`;
|
|
354
|
+
io.print(` ${green('✓')} ${address}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
io.print(` ${yellow('!')} Skipped: ${error.message}`);
|
|
360
|
+
}
|
|
361
|
+
// ── 7. The API console ───────────────────────────────────────────────────
|
|
362
|
+
if (spec) {
|
|
363
|
+
step(io, 7, 'The API console');
|
|
364
|
+
const mode = yes
|
|
365
|
+
? 1
|
|
366
|
+
: await choose(io, 'Where should "Try it" send requests from?', [
|
|
367
|
+
`The reader's browser ${dim('(your API must allow CORS from the docs address)')}`,
|
|
368
|
+
`Through DocuGate ${dim('(no CORS setup; readers must be signed in)')}`,
|
|
369
|
+
]);
|
|
370
|
+
await apiFetch(`/api/proxy/spaces/${space.id}`, {
|
|
371
|
+
method: 'PUT',
|
|
372
|
+
body: JSON.stringify({ mode: mode === 2 ? 'proxy' : 'direct' }),
|
|
373
|
+
})
|
|
374
|
+
.then(() => io.print(` ${green('✓')} ${mode === 2 ? 'Through DocuGate' : "From the reader's browser"}`))
|
|
375
|
+
.catch((error) => io.print(` ${yellow('!')} Skipped: ${error.message}`));
|
|
376
|
+
}
|
|
377
|
+
// ── 8. API keys ──────────────────────────────────────────────────────────
|
|
378
|
+
step(io, 8, 'API keys');
|
|
379
|
+
if (!(await confirm('Let developers generate their own API keys on the docs?'))) {
|
|
380
|
+
io.print(` ${dim('Skipped. Switch them on later in the space settings, under Developer.')}`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
const suggested = (summary?.tags ?? []).map(scopeFromTag).filter((s) => Boolean(s)).slice(0, 8);
|
|
384
|
+
const scopes = (await ask('Scopes a new key gets (comma-separated)', suggested.length ? suggested.join(', ') : 'read'))
|
|
385
|
+
.split(',')
|
|
386
|
+
.map((s) => s.trim().toLowerCase())
|
|
387
|
+
.filter((s) => /^[a-z0-9:_-]{1,32}$/.test(s));
|
|
388
|
+
try {
|
|
389
|
+
await apiFetch(`/api/keys/spaces/${space.id}/config`, {
|
|
390
|
+
method: 'PUT',
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
enabled: true,
|
|
393
|
+
header: 'authorization',
|
|
394
|
+
scopes: scopes.map((name) => ({ name, description: `Granted to every new key: ${name}`, default: true })),
|
|
395
|
+
allowlist: null,
|
|
396
|
+
defaultLimit: 1000,
|
|
397
|
+
defaultExpiryDays: 90,
|
|
398
|
+
maxPerReader: 5,
|
|
399
|
+
}),
|
|
400
|
+
});
|
|
401
|
+
io.print(` ${green('✓')} Keys on ${dim(`· ${scopes.join(', ') || 'no scopes'} · 1,000 calls and 90 days per key to start`)}`);
|
|
402
|
+
const { secret } = (await (await apiFetch(`/api/keys/spaces/${space.id}/verify-secret`, { method: 'POST' })).json());
|
|
403
|
+
io.print('');
|
|
404
|
+
io.print(` Your backend checks each key with this secret. ${bold('It is shown once.')}`);
|
|
405
|
+
io.print(` ${gold(`DOCUGATE_VERIFY_SECRET=${secret}`)}`);
|
|
406
|
+
const envFile = join(root, '.env');
|
|
407
|
+
const ignored = (() => {
|
|
408
|
+
try {
|
|
409
|
+
execFileSync('git', ['check-ignore', '-q', '.env'], { cwd: root, stdio: 'ignore' });
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
})();
|
|
416
|
+
if (ignored && (await confirm('Add it to .env (git ignores that file)?'))) {
|
|
417
|
+
appendFileSync(envFile, `${existsSync(envFile) ? '\n' : ''}DOCUGATE_VERIFY_SECRET=${secret}\n`);
|
|
418
|
+
io.print(` ${green('✓')} Added to .env`);
|
|
419
|
+
}
|
|
420
|
+
else if (!ignored) {
|
|
421
|
+
io.print(` ${dim('.env is not git-ignored here, so it was not written. Put the secret in your host’s environment settings.')}`);
|
|
422
|
+
}
|
|
423
|
+
const framework = detectFramework(root);
|
|
424
|
+
io.print('');
|
|
425
|
+
io.print(` Then, in your ${framework ?? 'backend'} app ${dim('(npm i docugate)')}:`);
|
|
426
|
+
for (const line of middlewareSnippet(framework))
|
|
427
|
+
io.print(` ${line ? gold(line) : ''}`);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
io.print(` ${yellow('!')} Keys were not switched on: ${error.message}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// ── Done ─────────────────────────────────────────────────────────────────
|
|
434
|
+
io.print('');
|
|
435
|
+
io.print(` ${bold('Your developer space is ready.')}`);
|
|
436
|
+
io.print(` Docs ${gold(address)}`);
|
|
437
|
+
io.print(` Settings ${SITE}/dashboard/spaces/${space.id}`);
|
|
438
|
+
io.print(` ${dim('Edit the pages in')} ${folder}/ ${dim('and push: the site updates on every push.')}`);
|
|
439
|
+
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { RunBob } from './tour-init.js';
|
|
2
|
+
import type { CreatedRepo } from './api.js';
|
|
3
|
+
import type { TourInitResult } from './tour-init.js';
|
|
4
|
+
import type { TourInstallResult } from './tour-install.js';
|
|
5
|
+
import type { LoginOptions } from './auth.js';
|
|
6
|
+
import type { Space } from './api.js';
|
|
7
|
+
export type InitResult = {
|
|
8
|
+
created: string[];
|
|
9
|
+
kept: string[];
|
|
10
|
+
docsDir: string;
|
|
11
|
+
};
|
|
12
|
+
export { asCommand } from './ui.js';
|
|
13
|
+
/**
|
|
14
|
+
* Sets a repository up for DocuGate: a docugate.json, and a first page if the
|
|
15
|
+
* docs folder has none. Never overwrites — running it twice is harmless, and
|
|
16
|
+
* running it in a repository that already has docs only adds what is missing.
|
|
17
|
+
*/
|
|
18
|
+
export declare function init(root: string, opts?: {
|
|
19
|
+
dir?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
}): InitResult;
|
|
22
|
+
export type Role = 'frontend' | 'backend' | 'both';
|
|
23
|
+
/** Minimal I/O surface so the flow can be driven by tests without a terminal. */
|
|
24
|
+
export interface IO {
|
|
25
|
+
isTTY: boolean;
|
|
26
|
+
ask(question: string, defaultAnswer: string): Promise<string>;
|
|
27
|
+
print(line: string): void;
|
|
28
|
+
/** Like ask, without echoing what is typed: for keys. */
|
|
29
|
+
askSecret?(question: string, defaultAnswer: string): Promise<string>;
|
|
30
|
+
}
|
|
31
|
+
/** Injectable for tests: replaces the real getSpaces call. */
|
|
32
|
+
export type GetSpacesFn = () => Promise<{
|
|
33
|
+
spaces: Space[];
|
|
34
|
+
}>;
|
|
35
|
+
/** Injectable for tests: replaces the real connectRepo call. */
|
|
36
|
+
export type ConnectRepoFn = (spaceId: string, repo: string, docsDir: string) => Promise<void>;
|
|
37
|
+
/** Injectable for tests: replaces the real getSession call. */
|
|
38
|
+
export type GetSessionFn = () => Promise<{
|
|
39
|
+
user: {
|
|
40
|
+
githubLogin: string;
|
|
41
|
+
} | null;
|
|
42
|
+
}>;
|
|
43
|
+
/** Injectable for tests: replaces the real login call. */
|
|
44
|
+
export type LoginFn = (baseUrl: string, opts?: LoginOptions) => Promise<{
|
|
45
|
+
token: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** Injectable for tests: replaces `git remote get-url origin`. Returns null if no remote. */
|
|
48
|
+
export type GetRemoteFn = (root: string) => string | null;
|
|
49
|
+
export interface InitFlowOptions {
|
|
50
|
+
dir?: string;
|
|
51
|
+
title?: string;
|
|
52
|
+
role?: Role;
|
|
53
|
+
yes?: boolean;
|
|
54
|
+
port?: number;
|
|
55
|
+
io?: IO;
|
|
56
|
+
runBob?: RunBob;
|
|
57
|
+
getSessionFn?: GetSessionFn;
|
|
58
|
+
getSpacesFn?: GetSpacesFn;
|
|
59
|
+
connectFn?: ConnectRepoFn;
|
|
60
|
+
loginFn?: LoginFn;
|
|
61
|
+
getRemoteFn?: GetRemoteFn;
|
|
62
|
+
/** Base URL for DocuGate (overrides DOCUGATE_URL env var). */
|
|
63
|
+
baseUrl?: string;
|
|
64
|
+
createSpaceFn?: (repo: string, name: string, docsDir: string) => Promise<{
|
|
65
|
+
name: string;
|
|
66
|
+
owner: string;
|
|
67
|
+
slug: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** Creates the documentation repository for a new space; injectable for tests. */
|
|
70
|
+
createDocsRepoFn?: (name: string, isPrivate: boolean, title: string) => Promise<CreatedRepo>;
|
|
71
|
+
/** Creates the docs repository with the GitHub CLI when DocuGate cannot; injectable for tests. */
|
|
72
|
+
ghDocsRepoFn?: (name: string, isPrivate: boolean, title: string, io: IO) => CreatedRepo | null;
|
|
73
|
+
/** Opens a URL in the browser; injectable for tests. */
|
|
74
|
+
openUrl?: (url: string) => void;
|
|
75
|
+
}
|
|
76
|
+
export interface InitFlowResult {
|
|
77
|
+
created: string[];
|
|
78
|
+
kept: string[];
|
|
79
|
+
docsDir: string;
|
|
80
|
+
role: Role | undefined;
|
|
81
|
+
tourResult?: TourInitResult;
|
|
82
|
+
tourError?: string;
|
|
83
|
+
installResult?: TourInstallResult;
|
|
84
|
+
connectError?: string;
|
|
85
|
+
/** Set when a folder above already has the project's setup; nothing was written here. */
|
|
86
|
+
alreadySetUp?: string;
|
|
87
|
+
}
|
|
88
|
+
export declare function makeDefaultIO(): IO;
|
|
89
|
+
/**
|
|
90
|
+
* The nearest folder above `root`, inside the same Git repository, that has
|
|
91
|
+
* DocuGate set up already (a docugate.json), if any.
|
|
92
|
+
*/
|
|
93
|
+
export declare function setUpAbove(root: string): string | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Sets one key in docugate.json, keeping everything else as it was.
|
|
96
|
+
*
|
|
97
|
+
* A file that cannot be read is left alone and reported. It used to be started over with
|
|
98
|
+
* just this key, which threw away a hand-written `title` and `sidebar` over one stray comma.
|
|
99
|
+
*/
|
|
100
|
+
export declare function saveSetting(root: string, key: string, value: unknown): void;
|
|
101
|
+
export declare const FRESHNESS: readonly [{
|
|
102
|
+
readonly value: "push";
|
|
103
|
+
readonly label: "On every push";
|
|
104
|
+
}, {
|
|
105
|
+
readonly value: "daily";
|
|
106
|
+
readonly label: "Once a day (recommended: fewer AI runs)";
|
|
107
|
+
}, {
|
|
108
|
+
readonly value: "weekly";
|
|
109
|
+
readonly label: "Once a week";
|
|
110
|
+
}, {
|
|
111
|
+
readonly value: "off";
|
|
112
|
+
readonly label: "Never, I will ask for it";
|
|
113
|
+
}];
|
|
114
|
+
/**
|
|
115
|
+
* What the repository probably is, from its folders and files, so the question
|
|
116
|
+
* can suggest an answer. Only a suggestion: the person always confirms it.
|
|
117
|
+
*/
|
|
118
|
+
export declare function guessRole(root: string): Role | undefined;
|
|
119
|
+
/**
|
|
120
|
+
* A numbered choice: prints the options, and takes only a number. Enter picks
|
|
121
|
+
* the default. Nobody types a word during setup.
|
|
122
|
+
*/
|
|
123
|
+
export declare function choose(io: IO, question: string, options: string[], fallback?: number): Promise<number>;
|
|
124
|
+
export declare function defaultGetRemote(root: string): string | null;
|
|
125
|
+
/**
|
|
126
|
+
* Runs the full interactive DocuGate setup wizard. Asks one short question,
|
|
127
|
+
* saves the result in docugate.json, conditionally runs tour init and tour
|
|
128
|
+
* install, and prints a summary.
|
|
129
|
+
*/
|
|
130
|
+
export declare function initFlow(root: string, options?: InitFlowOptions): Promise<InitFlowResult>;
|