@hyper.authio/cli 0.0.0-stage → 0.11.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 +68 -2
- package/dist/cli.js +497 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +514 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +264 -0
- package/dist/index.d.ts +264 -0
- package/dist/index.js +489 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -4
package/README.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hyper.authio/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Apply an Authio client's integration plan to your project, on your machine, with one command.
|
|
4
|
+
|
|
5
|
+
In the Authio console, open a client's integration guide, pick **Next.js · Auth.js v5** and press **Generate command**:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @hyper.authio/cli@latest init --ticket ait_...
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The code in the command works once and for ten minutes, and it only reads that client's integration plan: the
|
|
12
|
+
packages, the files and the environment variables, already filled in with the client's issuer, identifier, scopes,
|
|
13
|
+
PKCE, algorithms and session model. The plan never carries the client secret. Authio never sees your repository.
|
|
14
|
+
|
|
15
|
+
## What `init` does
|
|
16
|
+
|
|
17
|
+
1. **Reads the project, without the network.** The `next` major (16 and later get `proxy.ts`, earlier ones
|
|
18
|
+
`middleware.ts`), the App Router (`app/` or `src/app/`), a `src/` folder and the package manager from the lockfile.
|
|
19
|
+
A project on the pages router or on NextAuth v4 is stopped with an explanation, before the code is used.
|
|
20
|
+
2. **Exchanges the code for the plan** and shows every file in full, every variable and every package.
|
|
21
|
+
3. **Asks before writing.** Files that do not exist are created. **A file you already have is never overwritten**: it
|
|
22
|
+
is listed as one to merge by hand, with the plan's version printed above.
|
|
23
|
+
4. **Fills in `.env.local`**, adding only the variables it does not have yet. The client secret (and any other secret
|
|
24
|
+
the plan names) is asked for in a hidden prompt and written only to `.env.local`; leave it empty to fill in later.
|
|
25
|
+
`AUTH_SECRET` is generated on your machine.
|
|
26
|
+
5. **Checks `.gitignore`** for `.env.local` and `.authio/` and offers to add them.
|
|
27
|
+
6. **Installs the packages** with your package manager, after asking.
|
|
28
|
+
|
|
29
|
+
It never commits, never runs git and never sends your code anywhere. It warns when the redirect your app will use
|
|
30
|
+
locally (`http://localhost:<dev port>/api/auth/callback/authio`) is not registered on the client, and when the client is
|
|
31
|
+
inactive.
|
|
32
|
+
|
|
33
|
+
## Confirm the first sign-in
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx @hyper.authio/cli verify # once
|
|
37
|
+
npx @hyper.authio/cli verify --wait # until the first sign-in arrives
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`init` keeps a verification code in `.authio/verification` (valid for 30 minutes). `verify` answers whether the client
|
|
41
|
+
has had a successful sign-in since then; the last step of the client's guide in the console shows the same.
|
|
42
|
+
|
|
43
|
+
## Options
|
|
44
|
+
|
|
45
|
+
| Option | Meaning |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `--ticket <code>` | The one-time code; or the `AUTHIO_TICKET` environment variable. |
|
|
48
|
+
| `--api <url>` | The Authio API address, for a Dedicated instance or QA. Default `https://authio.api.hyperio.tech`. The console adds it to the command when it is not the default. |
|
|
49
|
+
| `--cwd <dir>` | The project folder. Default: the current folder. |
|
|
50
|
+
| `--dry-run` | Show the plan without writing, installing or asking for secrets. The code is used up all the same. |
|
|
51
|
+
| `--yes`, `-y` | Answer yes to every question. Required without an interactive terminal, where secrets are written empty. |
|
|
52
|
+
| `--verification <code>` | With `verify`: the code to check instead of the saved one. |
|
|
53
|
+
| `--wait` | With `verify`: ask every three seconds until the sign-in arrives or the verification expires. |
|
|
54
|
+
|
|
55
|
+
Exit codes: `0` done, `1` error (printed as `Error (<code>): <message>`), `2` nothing done (declined, or no sign-in
|
|
56
|
+
yet).
|
|
57
|
+
|
|
58
|
+
## From code or an agent
|
|
59
|
+
|
|
60
|
+
The same steps are exported for tests and tools: `detectProject`, `redeemTicket`, `planChanges`, `runInit` and
|
|
61
|
+
`runVerify`, with every effect (network, prompts, process) behind the `CliIo` interface. An agent connected to Authio's
|
|
62
|
+
MCP server can also ask for the plan with the `integration_plan` tool and apply it itself, showing you each change.
|
|
63
|
+
|
|
64
|
+
## Security
|
|
65
|
+
|
|
66
|
+
Report a vulnerability privately, by email to **security@hyperio.tech**, and not in an issue or anywhere else in
|
|
67
|
+
public. Authio's [vulnerability disclosure policy](https://authio.hyperio.tech/docs/trust/vulnerability-disclosure)
|
|
68
|
+
says what is in scope and what happens after a report. Only the latest published minor receives security fixes.
|
|
69
|
+
Questions and bugs that are not about security go to [support](https://authio.hyperio.tech/docs/account/support).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, appendFileSync, chmodSync, statSync } from 'fs';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { createInterface } from 'readline';
|
|
5
|
+
import { join, resolve, dirname, sep, normalize, isAbsolute } from 'path';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
7
|
+
|
|
8
|
+
function ask(question, hidden) {
|
|
9
|
+
const input = process.stdin;
|
|
10
|
+
const output = process.stdout;
|
|
11
|
+
if (!hidden || !input.isTTY) {
|
|
12
|
+
const lines = createInterface({ input, output });
|
|
13
|
+
return new Promise(
|
|
14
|
+
(resolve2) => lines.question(question, (answer) => {
|
|
15
|
+
lines.close();
|
|
16
|
+
resolve2(answer);
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve2, reject) => {
|
|
21
|
+
let value = "";
|
|
22
|
+
output.write(question);
|
|
23
|
+
input.setRawMode(true);
|
|
24
|
+
input.resume();
|
|
25
|
+
input.setEncoding("utf8");
|
|
26
|
+
const finish = () => {
|
|
27
|
+
input.setRawMode(false);
|
|
28
|
+
input.pause();
|
|
29
|
+
input.removeListener("data", onData);
|
|
30
|
+
output.write("\n");
|
|
31
|
+
};
|
|
32
|
+
const onData = (chunk) => {
|
|
33
|
+
for (const character of chunk) {
|
|
34
|
+
if (character === "\r" || character === "\n") {
|
|
35
|
+
finish();
|
|
36
|
+
resolve2(value);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (character === "") {
|
|
40
|
+
finish();
|
|
41
|
+
reject(new Error("Cancelled."));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (character === "\x7F" || character === "\b") value = value.slice(0, -1);
|
|
45
|
+
else if (character >= " ") value += character;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
input.on("data", onData);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function windowsCommandLine(command, args) {
|
|
52
|
+
return [command, ...args.map((arg) => `"${arg.replace(/"/g, '""')}"`)].join(" ");
|
|
53
|
+
}
|
|
54
|
+
function createTerminalIo() {
|
|
55
|
+
return {
|
|
56
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
57
|
+
print: (line) => process.stdout.write(`${line}
|
|
58
|
+
`),
|
|
59
|
+
confirm: async (question, defaultYes) => {
|
|
60
|
+
const answer = (await ask(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, false)).trim().toLowerCase();
|
|
61
|
+
return answer === "" ? defaultYes : answer === "y" || answer === "yes";
|
|
62
|
+
},
|
|
63
|
+
promptSecret: (question) => ask(question, true),
|
|
64
|
+
run: (command, args, cwd) => new Promise((resolve2) => {
|
|
65
|
+
const child = process.platform === "win32" ? spawn(windowsCommandLine(command, args), { cwd, stdio: "inherit", shell: true }) : spawn(command, [...args], { cwd, stdio: "inherit" });
|
|
66
|
+
child.on("error", () => resolve2(127));
|
|
67
|
+
child.on("close", (code) => resolve2(code ?? 1));
|
|
68
|
+
}),
|
|
69
|
+
isInteractive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
70
|
+
now: () => Date.now(),
|
|
71
|
+
sleep: (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/errors.ts
|
|
76
|
+
var AuthioCliError = class extends Error {
|
|
77
|
+
/** What went wrong, stable across versions. */
|
|
78
|
+
code;
|
|
79
|
+
/** The process exit code the CLI ends with. */
|
|
80
|
+
exitCode;
|
|
81
|
+
constructor(code, message, options) {
|
|
82
|
+
super(message, options);
|
|
83
|
+
this.name = "AuthioCliError";
|
|
84
|
+
this.code = code;
|
|
85
|
+
this.exitCode = options?.exitCode ?? 1;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/args.ts
|
|
90
|
+
var DEFAULT_API = "https://authio.api.hyperio.tech";
|
|
91
|
+
var VALUE_OPTIONS = /* @__PURE__ */ new Set(["--ticket", "--verification", "--api", "--cwd"]);
|
|
92
|
+
var FLAG_OPTIONS = /* @__PURE__ */ new Set(["--dry-run", "--yes", "-y", "--wait", "--help", "-h", "--version", "-v"]);
|
|
93
|
+
function parseArguments(argv) {
|
|
94
|
+
const values = /* @__PURE__ */ new Map();
|
|
95
|
+
const flags = /* @__PURE__ */ new Set();
|
|
96
|
+
const positional = [];
|
|
97
|
+
for (let index = 0; index < argv.length; index++) {
|
|
98
|
+
const current = argv[index];
|
|
99
|
+
if (!current.startsWith("-")) {
|
|
100
|
+
positional.push(current);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const [name, inline] = current.includes("=") ? [current.slice(0, current.indexOf("=")), current.slice(current.indexOf("=") + 1)] : [current, void 0];
|
|
104
|
+
if (VALUE_OPTIONS.has(name)) {
|
|
105
|
+
const value = inline ?? argv[++index];
|
|
106
|
+
if (value === void 0 || value.startsWith("--")) throw new AuthioCliError("invalid_arguments", `${name} needs a value.`);
|
|
107
|
+
values.set(name, value);
|
|
108
|
+
} else if (FLAG_OPTIONS.has(name) && inline === void 0) {
|
|
109
|
+
flags.add(name);
|
|
110
|
+
} else {
|
|
111
|
+
throw new AuthioCliError("invalid_arguments", `Unknown option ${current}. Run "authio --help" to see the options.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const named = positional[0];
|
|
115
|
+
const command = flags.has("--help") || flags.has("-h") ? "help" : flags.has("--version") || flags.has("-v") ? "version" : named === void 0 ? "help" : named;
|
|
116
|
+
if (command !== "init" && command !== "verify" && command !== "help" && command !== "version")
|
|
117
|
+
throw new AuthioCliError("invalid_arguments", `Unknown command "${command}". Use "init" or "verify".`);
|
|
118
|
+
return {
|
|
119
|
+
command,
|
|
120
|
+
ticket: values.get("--ticket"),
|
|
121
|
+
verification: values.get("--verification"),
|
|
122
|
+
api: values.get("--api"),
|
|
123
|
+
cwd: values.get("--cwd"),
|
|
124
|
+
dryRun: flags.has("--dry-run"),
|
|
125
|
+
yes: flags.has("--yes") || flags.has("-y"),
|
|
126
|
+
wait: flags.has("--wait")
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function apiAddress(api) {
|
|
130
|
+
const address = (api ?? DEFAULT_API).trim().replace(/\/+$/, "");
|
|
131
|
+
if (!/^https?:\/\/[^/\s]+/i.test(address)) throw new AuthioCliError("invalid_arguments", `--api must be an http or https address, not "${api}".`);
|
|
132
|
+
return address;
|
|
133
|
+
}
|
|
134
|
+
var HELP = `Authio CLI
|
|
135
|
+
|
|
136
|
+
Usage
|
|
137
|
+
npx @hyper.authio/cli init --ticket ait_... Apply a client's integration plan to this project
|
|
138
|
+
npx @hyper.authio/cli verify [--wait] Check whether the first sign-in has arrived
|
|
139
|
+
|
|
140
|
+
Options
|
|
141
|
+
--ticket <code> The one-time code from the client's guide in the console (or AUTHIO_TICKET)
|
|
142
|
+
--api <url> The Authio API address (default ${DEFAULT_API})
|
|
143
|
+
--cwd <dir> The project folder (default: the current folder)
|
|
144
|
+
--dry-run Show the plan without writing anything (the code is still used up)
|
|
145
|
+
--yes, -y Answer yes to every question; required without an interactive terminal
|
|
146
|
+
--verification <code> The verification code (default: the one init saved in .authio/verification)
|
|
147
|
+
--wait With verify: keep checking until the first sign-in arrives
|
|
148
|
+
|
|
149
|
+
The CLI never overwrites a file, never commits and never sends your code anywhere.`;
|
|
150
|
+
var SUPPORTED_PLAN_VERSIONS = [1];
|
|
151
|
+
function normalizePlan(plan) {
|
|
152
|
+
const lower = (value, fallback) => value ? String(value).toLowerCase() : fallback;
|
|
153
|
+
return {
|
|
154
|
+
...plan,
|
|
155
|
+
packages: plan.packages ?? [],
|
|
156
|
+
redirectUris: plan.redirectUris ?? [],
|
|
157
|
+
files: (plan.files ?? []).map((file) => ({ ...file, root: lower(file.root, "project"), content: file.content ?? "" })),
|
|
158
|
+
environment: (plan.environment ?? []).map((variable) => ({
|
|
159
|
+
...variable,
|
|
160
|
+
source: lower(variable.source, "secret")
|
|
161
|
+
}))
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function assertPlanVersion(plan) {
|
|
165
|
+
if (!SUPPORTED_PLAN_VERSIONS.includes(plan.planVersion))
|
|
166
|
+
throw new AuthioCliError(
|
|
167
|
+
"plan_version_unsupported",
|
|
168
|
+
`This plan is version ${plan.planVersion}, which this CLI does not know. Run "npx @hyper.authio/cli@latest" with a new code.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
function placePath(project, file) {
|
|
172
|
+
const relative = normalize(file.path).replace(/\\/g, "/");
|
|
173
|
+
if (!relative || isAbsolute(file.path) || /^[a-zA-Z]:/.test(file.path) || relative.split("/").includes(".."))
|
|
174
|
+
throw new AuthioCliError("plan_path_invalid", `The plan names a path outside the project ("${file.path}"); nothing was written.`);
|
|
175
|
+
return file.root === "source" && project.srcDir ? `src/${relative}` : relative;
|
|
176
|
+
}
|
|
177
|
+
function readEnvKeys(path) {
|
|
178
|
+
if (!existsSync(path)) return /* @__PURE__ */ new Set();
|
|
179
|
+
const keys = /* @__PURE__ */ new Set();
|
|
180
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
181
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
182
|
+
if (match) keys.add(match[1]);
|
|
183
|
+
}
|
|
184
|
+
return keys;
|
|
185
|
+
}
|
|
186
|
+
var LOCAL_ORIGIN_VARIABLE = "AUTH_URL";
|
|
187
|
+
var LOCAL_ORIGIN_COMMENT = "Where this app runs on this machine; set the public address where it is deployed.";
|
|
188
|
+
function localOrigin(project) {
|
|
189
|
+
return `http://localhost:${project.devPort}`;
|
|
190
|
+
}
|
|
191
|
+
function planChanges(project, plan) {
|
|
192
|
+
const files = plan.files.map((file) => {
|
|
193
|
+
const path = placePath(project, file);
|
|
194
|
+
const target = join(project.root, ...path.split("/"));
|
|
195
|
+
if (!target.startsWith(project.root + sep) && target !== project.root)
|
|
196
|
+
throw new AuthioCliError("plan_path_invalid", `The plan names a path outside the project ("${file.path}"); nothing was written.`);
|
|
197
|
+
return { path, target, content: file.content, action: existsSync(target) ? "merge" : "create" };
|
|
198
|
+
});
|
|
199
|
+
const existing = readEnvKeys(join(project.root, ".env.local"));
|
|
200
|
+
const localRedirect = `${localOrigin(project)}${plan.callbackPath}`;
|
|
201
|
+
return {
|
|
202
|
+
files,
|
|
203
|
+
environment: plan.environment.filter((variable) => !existing.has(variable.name)).map((variable) => variable.name === LOCAL_ORIGIN_VARIABLE && variable.source === "plan" ? { ...variable, value: localOrigin(project), comment: LOCAL_ORIGIN_COMMENT } : variable),
|
|
204
|
+
keptEnvironment: plan.environment.filter((variable) => existing.has(variable.name)).map((variable) => variable.name),
|
|
205
|
+
localRedirect,
|
|
206
|
+
redirectRegistered: plan.redirectUris.some((uri) => uri.replace(/\/+$/, "") === localRedirect)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/api.ts
|
|
211
|
+
async function post(fetcher, api, path, body, invalidCode) {
|
|
212
|
+
const url = `${api}${path}`;
|
|
213
|
+
let response;
|
|
214
|
+
try {
|
|
215
|
+
response = await fetcher(url, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
218
|
+
body: JSON.stringify(body)
|
|
219
|
+
});
|
|
220
|
+
} catch (cause) {
|
|
221
|
+
throw new AuthioCliError("api_unreachable", `Could not reach Authio at ${api}. Check the address (--api) and your connection.`, { cause });
|
|
222
|
+
}
|
|
223
|
+
let envelope = {};
|
|
224
|
+
try {
|
|
225
|
+
envelope = await response.json();
|
|
226
|
+
} catch {
|
|
227
|
+
envelope = {};
|
|
228
|
+
}
|
|
229
|
+
if (response.status === 401)
|
|
230
|
+
throw new AuthioCliError(
|
|
231
|
+
invalidCode,
|
|
232
|
+
invalidCode === "ticket_invalid" ? "This code was already used or has expired. Generate another command in the client's guide in the Authio console." : "This verification has expired. The last step of the client's guide in the console still shows the first sign-in."
|
|
233
|
+
);
|
|
234
|
+
if (response.status === 429) throw new AuthioCliError("rate_limited", "Authio is refusing more attempts for now. Wait a minute and try again.");
|
|
235
|
+
if (!response.ok || envelope.data === void 0) {
|
|
236
|
+
const detail = envelope.notifications?.map((notification) => notification.message).filter(Boolean).join(" ");
|
|
237
|
+
throw new AuthioCliError("api_error", `Authio answered ${response.status}${envelope.errorCode ? ` (${envelope.errorCode})` : ""}${detail ? `: ${detail}` : "."}`);
|
|
238
|
+
}
|
|
239
|
+
return envelope.data;
|
|
240
|
+
}
|
|
241
|
+
async function redeemTicket(fetcher, api, ticket, nextMajor) {
|
|
242
|
+
const redemption = await post(fetcher, api, "/api/v1/integration-plans/redeem", { ticket, nextMajor }, "ticket_invalid");
|
|
243
|
+
return { ...redemption, plan: normalizePlan(redemption.plan) };
|
|
244
|
+
}
|
|
245
|
+
function checkVerification(fetcher, api, verification) {
|
|
246
|
+
return post(fetcher, api, "/api/v1/integration-plans/verify", { verification }, "verification_invalid");
|
|
247
|
+
}
|
|
248
|
+
var IGNORED_PATHS = [".env.local", ".authio/"];
|
|
249
|
+
function patternCovers(pattern, path) {
|
|
250
|
+
const trimmed = pattern.trim();
|
|
251
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) return false;
|
|
252
|
+
const bare = trimmed.replace(/^\//, "");
|
|
253
|
+
const wantsDirectory = path.endsWith("/");
|
|
254
|
+
const name = path.replace(/\/$/, "");
|
|
255
|
+
if (bare.endsWith("/") && !wantsDirectory) return false;
|
|
256
|
+
const glob = bare.replace(/\/$/, "");
|
|
257
|
+
if (glob.includes("/")) return false;
|
|
258
|
+
const expression = new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
|
|
259
|
+
return expression.test(name);
|
|
260
|
+
}
|
|
261
|
+
function uncoveredPaths(path) {
|
|
262
|
+
const patterns = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
|
|
263
|
+
return IGNORED_PATHS.filter((ignored) => !patterns.some((pattern) => patternCovers(pattern, ignored)));
|
|
264
|
+
}
|
|
265
|
+
var isDirectory = (path) => existsSync(path) && statSync(path).isDirectory();
|
|
266
|
+
function majorOf(range) {
|
|
267
|
+
const match = /(\d+)/.exec(range ?? "");
|
|
268
|
+
return match ? Number(match[1]) : void 0;
|
|
269
|
+
}
|
|
270
|
+
function detectProject(root) {
|
|
271
|
+
const manifestPath = join(root, "package.json");
|
|
272
|
+
if (!existsSync(manifestPath))
|
|
273
|
+
throw new AuthioCliError("project_not_found", `No package.json in ${root}. Run the command in your Next.js project folder, or pass --cwd.`);
|
|
274
|
+
let manifest2;
|
|
275
|
+
try {
|
|
276
|
+
manifest2 = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
277
|
+
} catch (cause) {
|
|
278
|
+
throw new AuthioCliError("project_not_found", `${manifestPath} could not be read as JSON.`, { cause });
|
|
279
|
+
}
|
|
280
|
+
const dependencies = { ...manifest2.devDependencies, ...manifest2.dependencies };
|
|
281
|
+
const nextMajor = majorOf(dependencies.next);
|
|
282
|
+
if (nextMajor === void 0)
|
|
283
|
+
throw new AuthioCliError("stack_not_detected", "This project does not depend on next. The plan this CLI applies is for Next.js with the App Router and Auth.js v5; for another stack, follow the client's guide in the console.");
|
|
284
|
+
const nextAuthMajor = majorOf(dependencies["next-auth"]);
|
|
285
|
+
if (nextAuthMajor !== void 0 && nextAuthMajor < 5)
|
|
286
|
+
throw new AuthioCliError("nextauth_v4_unsupported", `This project uses next-auth ${dependencies["next-auth"]}. The plan is written for Auth.js v5; follow the NextAuth v4 recipe in the client's guide instead.`);
|
|
287
|
+
const srcDir = isDirectory(join(root, "src", "app"));
|
|
288
|
+
const appDir = srcDir || isDirectory(join(root, "app"));
|
|
289
|
+
if (!appDir) {
|
|
290
|
+
const pages = isDirectory(join(root, "pages")) || isDirectory(join(root, "src", "pages"));
|
|
291
|
+
throw new AuthioCliError(
|
|
292
|
+
pages ? "pages_router_unsupported" : "stack_not_detected",
|
|
293
|
+
pages ? "This project uses the pages router. The plan is written for the App Router; follow the client's guide in the console instead." : "No app/ or src/app/ folder was found. Create the App Router folder first, or run the command in the app's folder."
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const packageManager = existsSync(join(root, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(root, "yarn.lock")) ? "yarn" : existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")) ? "bun" : "npm";
|
|
297
|
+
const port = /(?:^|\s)(?:-p|--port)(?:\s+|=)(\d{2,5})(?:\s|$)/.exec(manifest2.scripts?.dev ?? "");
|
|
298
|
+
return { root, nextMajor, srcDir, packageManager, devPort: port ? Number(port[1]) : 3e3 };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/init.ts
|
|
302
|
+
var VERIFICATION_FILE = join(".authio", "verification");
|
|
303
|
+
function installCommand(manager, specs) {
|
|
304
|
+
return manager === "npm" ? ["npm", ["install", ...specs]] : [manager, ["add", ...specs]];
|
|
305
|
+
}
|
|
306
|
+
function envLine(name, value) {
|
|
307
|
+
return value.includes('"') ? `${name}='${value}'` : `${name}="${value}"`;
|
|
308
|
+
}
|
|
309
|
+
function describe(io, project, plan, changes) {
|
|
310
|
+
io.print("");
|
|
311
|
+
io.print(`Authio plan for client "${plan.client}" in realm "${plan.realm}" (${plan.stack})`);
|
|
312
|
+
io.print(`Project: Next.js ${project.nextMajor}${project.srcDir ? ", src/ folder" : ""}, ${project.packageManager}`);
|
|
313
|
+
io.print("");
|
|
314
|
+
for (const file of changes.files) {
|
|
315
|
+
io.print(file.action === "create" ? ` + ${file.path}` : ` ! ${file.path} already exists: left as it is, merge it by hand`);
|
|
316
|
+
for (const line of file.content.split(/\r?\n/)) io.print(` ${line}`);
|
|
317
|
+
}
|
|
318
|
+
io.print("");
|
|
319
|
+
io.print(" .env.local");
|
|
320
|
+
for (const variable of changes.environment) {
|
|
321
|
+
const how = variable.source === "secret" ? "asked for, hidden" : variable.source === "generate" ? "generated on this machine" : variable.value ?? "";
|
|
322
|
+
io.print(` + ${variable.name} ${variable.source === "plan" ? `= ${how}` : `(${how})`}`);
|
|
323
|
+
}
|
|
324
|
+
for (const name of changes.keptEnvironment) io.print(` = ${name} (already set, kept)`);
|
|
325
|
+
if (plan.packages.length > 0) io.print(`
|
|
326
|
+
Packages: ${plan.packages.map((item) => `${item.name}@${item.version}`).join(" ")}`);
|
|
327
|
+
if (!changes.redirectRegistered)
|
|
328
|
+
io.print(`
|
|
329
|
+
Warning: the client does not register ${changes.localRedirect}. Sign-in will fail until you add it as a redirect URI on the client in the Authio console.`);
|
|
330
|
+
if (!plan.clientActive) io.print("\n Warning: this client is inactive or in quarantine in Authio, so sign-in is refused until it is active again.");
|
|
331
|
+
io.print("");
|
|
332
|
+
}
|
|
333
|
+
async function valueOf(io, variable, emptySecrets) {
|
|
334
|
+
if (variable.source === "plan") return variable.value ?? "";
|
|
335
|
+
if (variable.source === "generate") return randomBytes(32).toString("base64");
|
|
336
|
+
if (!io.isInteractive) {
|
|
337
|
+
emptySecrets.push(variable.name);
|
|
338
|
+
return "";
|
|
339
|
+
}
|
|
340
|
+
const typed = (await io.promptSecret(`${variable.name}${variable.comment ? ` (${variable.comment})` : ""}, hidden, Enter to leave empty: `)).trim();
|
|
341
|
+
if (!typed) emptySecrets.push(variable.name);
|
|
342
|
+
return typed;
|
|
343
|
+
}
|
|
344
|
+
async function runInit(options, io) {
|
|
345
|
+
const result = { exitCode: 0, created: [], toMerge: [], addedEnvironment: [], emptySecrets: [], plan: void 0 };
|
|
346
|
+
const ticket = options.ticket?.trim();
|
|
347
|
+
if (!ticket)
|
|
348
|
+
throw new AuthioCliError("ticket_missing", "No code given. Open the client's guide in the Authio console, choose Next.js \xB7 Auth.js v5 and press Generate command.");
|
|
349
|
+
const project = detectProject(options.cwd);
|
|
350
|
+
if (!options.dryRun && !options.yes && !io.isInteractive)
|
|
351
|
+
throw new AuthioCliError("confirmation_required", "There is no terminal to confirm in. Run with --yes to apply the plan, or --dry-run to see it first.");
|
|
352
|
+
const redemption = await redeemTicket(io.fetch, options.api, ticket, project.nextMajor);
|
|
353
|
+
const plan = redemption.plan;
|
|
354
|
+
result.plan = plan;
|
|
355
|
+
assertPlanVersion(plan);
|
|
356
|
+
const changes = planChanges(project, plan);
|
|
357
|
+
result.toMerge = changes.files.filter((file) => file.action === "merge").map((file) => file.path);
|
|
358
|
+
describe(io, project, plan, changes);
|
|
359
|
+
if (options.dryRun) {
|
|
360
|
+
io.print("Dry run: nothing was written. The code is used up; generate another command to apply the plan.");
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
const creating = changes.files.filter((file) => file.action === "create");
|
|
364
|
+
const agreed = options.yes || await io.confirm(`Create ${creating.length} file(s) and add ${changes.environment.length} variable(s) to .env.local?`, true);
|
|
365
|
+
if (!agreed) {
|
|
366
|
+
io.print("Nothing was written.");
|
|
367
|
+
result.exitCode = 2;
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
const lines = [];
|
|
371
|
+
for (const variable of changes.environment) {
|
|
372
|
+
const value = await valueOf(io, variable, result.emptySecrets);
|
|
373
|
+
if (variable.comment) lines.push(`# ${variable.comment}`);
|
|
374
|
+
lines.push(envLine(variable.name, value));
|
|
375
|
+
result.addedEnvironment.push(variable.name);
|
|
376
|
+
}
|
|
377
|
+
for (const file of creating) {
|
|
378
|
+
mkdirSync(dirname(file.target), { recursive: true });
|
|
379
|
+
writeFileSync(file.target, file.content.endsWith("\n") ? file.content : `${file.content}
|
|
380
|
+
`, { flag: "wx" });
|
|
381
|
+
result.created.push(file.path);
|
|
382
|
+
}
|
|
383
|
+
if (lines.length > 0) {
|
|
384
|
+
const envPath = join(project.root, ".env.local");
|
|
385
|
+
const current = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
386
|
+
const separator = current && !current.endsWith("\n") ? "\n" : "";
|
|
387
|
+
appendFileSync(envPath, `${separator}${current ? "\n" : ""}# Added by @hyper.authio/cli for the Authio client "${plan.client}"
|
|
388
|
+
${lines.join("\n")}
|
|
389
|
+
`);
|
|
390
|
+
}
|
|
391
|
+
const gitignorePath = join(project.root, ".gitignore");
|
|
392
|
+
const missing = uncoveredPaths(gitignorePath);
|
|
393
|
+
if (missing.length > 0 && (options.yes || await io.confirm(`Add ${missing.join(" and ")} to .gitignore so they are never committed?`, true))) {
|
|
394
|
+
const current = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
|
|
395
|
+
appendFileSync(gitignorePath, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}
|
|
396
|
+
`);
|
|
397
|
+
} else if (missing.length > 0) {
|
|
398
|
+
io.print(`Warning: ${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} not in .gitignore. Never commit ${IGNORED_PATHS.join(" or ")}.`);
|
|
399
|
+
}
|
|
400
|
+
const verificationPath = join(project.root, VERIFICATION_FILE);
|
|
401
|
+
mkdirSync(dirname(verificationPath), { recursive: true });
|
|
402
|
+
writeFileSync(verificationPath, JSON.stringify({ api: options.api, verification: redemption.verification, expiresAt: redemption.verificationExpiresAt }), {
|
|
403
|
+
mode: 384
|
|
404
|
+
});
|
|
405
|
+
try {
|
|
406
|
+
chmodSync(verificationPath, 384);
|
|
407
|
+
} catch {
|
|
408
|
+
}
|
|
409
|
+
if (plan.packages.length > 0) {
|
|
410
|
+
const [command, args] = installCommand(project.packageManager, plan.packages.map((item) => `${item.name}@${item.version}`));
|
|
411
|
+
const install = options.yes || await io.confirm(`Install ${plan.packages.map((item) => item.name).join(", ")} with ${command}?`, true);
|
|
412
|
+
if (install) {
|
|
413
|
+
const code = await io.run(command, args, project.root);
|
|
414
|
+
if (code !== 0) io.print(`The install failed (exit ${code}). Run it yourself: ${command} ${args.join(" ")}`);
|
|
415
|
+
} else {
|
|
416
|
+
io.print(`Install them when you are ready: ${command} ${args.join(" ")}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
io.print("");
|
|
420
|
+
io.print(`Created: ${result.created.length > 0 ? result.created.join(", ") : "nothing"}`);
|
|
421
|
+
if (result.toMerge.length > 0) io.print(`Merge by hand: ${result.toMerge.join(", ")} (the plan's version is printed above)`);
|
|
422
|
+
if (result.emptySecrets.length > 0) io.print(`Fill in .env.local: ${result.emptySecrets.join(", ")}`);
|
|
423
|
+
io.print(`Next: start the app, sign in, then run "npx @hyper.authio/cli verify".`);
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
var VERIFY_INTERVAL_MS = 3e3;
|
|
427
|
+
function readSaved(cwd) {
|
|
428
|
+
const path = join(cwd, VERIFICATION_FILE);
|
|
429
|
+
if (!existsSync(path)) return {};
|
|
430
|
+
try {
|
|
431
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
432
|
+
} catch {
|
|
433
|
+
return {};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function runVerify(options, io) {
|
|
437
|
+
const saved = readSaved(options.cwd);
|
|
438
|
+
const verification = options.verification ?? saved.verification;
|
|
439
|
+
const api = options.api ?? saved.api ?? options.defaultApi;
|
|
440
|
+
if (!verification)
|
|
441
|
+
throw new AuthioCliError("verification_missing", `No verification found. Run "authio init" first, or pass --verification.`);
|
|
442
|
+
const expiresAt = saved.expiresAt && !options.verification ? Date.parse(saved.expiresAt) : Number.NaN;
|
|
443
|
+
if (options.wait) io.print("Waiting for the first sign-in through the app (Ctrl+C to stop)\u2026");
|
|
444
|
+
for (; ; ) {
|
|
445
|
+
const answer = await checkVerification(io.fetch, api, verification);
|
|
446
|
+
if (answer.signedIn) {
|
|
447
|
+
io.print(`Signed in: the first sign-in arrived${answer.firstSignInAt ? ` at ${answer.firstSignInAt}` : ""}. The integration works.`);
|
|
448
|
+
return { exitCode: 0, firstSignInAt: answer.firstSignInAt };
|
|
449
|
+
}
|
|
450
|
+
if (!options.wait || !Number.isNaN(expiresAt) && io.now() + VERIFY_INTERVAL_MS >= expiresAt) {
|
|
451
|
+
io.print("No sign-in has arrived yet. Start the app, sign in, and run this again (or add --wait).");
|
|
452
|
+
return { exitCode: 2, firstSignInAt: null };
|
|
453
|
+
}
|
|
454
|
+
await io.sleep(VERIFY_INTERVAL_MS);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/main.ts
|
|
459
|
+
async function main(argv, io, environment, version) {
|
|
460
|
+
try {
|
|
461
|
+
const args = parseArguments(argv);
|
|
462
|
+
const cwd = resolve(args.cwd ?? ".");
|
|
463
|
+
if (args.command === "help") {
|
|
464
|
+
io.print(HELP);
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
if (args.command === "version") {
|
|
468
|
+
io.print(version);
|
|
469
|
+
return 0;
|
|
470
|
+
}
|
|
471
|
+
if (args.command === "init") {
|
|
472
|
+
const result2 = await runInit(
|
|
473
|
+
{ ticket: args.ticket ?? environment.AUTHIO_TICKET, api: apiAddress(args.api), cwd, dryRun: args.dryRun, yes: args.yes },
|
|
474
|
+
io
|
|
475
|
+
);
|
|
476
|
+
return result2.exitCode;
|
|
477
|
+
}
|
|
478
|
+
const result = await runVerify(
|
|
479
|
+
{ verification: args.verification, api: args.api ? apiAddress(args.api) : void 0, defaultApi: DEFAULT_API, cwd, wait: args.wait },
|
|
480
|
+
io
|
|
481
|
+
);
|
|
482
|
+
return result.exitCode;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (error instanceof AuthioCliError) {
|
|
485
|
+
io.print(`Error (${error.code}): ${error.message}`);
|
|
486
|
+
return error.exitCode;
|
|
487
|
+
}
|
|
488
|
+
io.print(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/cli.ts
|
|
494
|
+
var manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
495
|
+
process.exitCode = await main(process.argv.slice(2), createTerminalIo(), process.env, manifest.version);
|
|
496
|
+
//# sourceMappingURL=cli.js.map
|
|
497
|
+
//# sourceMappingURL=cli.js.map
|