@gjsify/cli 0.4.36 → 0.4.37

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.
@@ -17,6 +17,8 @@ export * from './workspace.js';
17
17
  export * from './pack.js';
18
18
  export * from './publish.js';
19
19
  export * from './whoami.js';
20
+ export * from './login.js';
21
+ export * from './logout.js';
20
22
  export * from './self-update.js';
21
23
  export * from './generate-installer.js';
22
24
  export * from './uninstall.js';
@@ -17,6 +17,8 @@ export * from './workspace.js';
17
17
  export * from './pack.js';
18
18
  export * from './publish.js';
19
19
  export * from './whoami.js';
20
+ export * from './login.js';
21
+ export * from './logout.js';
20
22
  export * from './self-update.js';
21
23
  export * from './generate-installer.js';
22
24
  export * from './uninstall.js';
@@ -0,0 +1,10 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface LoginOptions {
3
+ registry?: string;
4
+ scope?: string;
5
+ username?: string;
6
+ otp?: string;
7
+ json?: boolean;
8
+ }
9
+ export declare const loginCommand: Command<unknown, LoginOptions>;
10
+ export {};
@@ -0,0 +1,139 @@
1
+ // `gjsify login [--registry <url>] [--scope @scope] [--username <u>] [--otp <code>]`
2
+ //
3
+ // Node-free `npm login` — completes the auth trio alongside `gjsify whoami` /
4
+ // `gjsify publish`. Implements npm's legacy (`--auth-type=legacy`) CouchDB
5
+ // credentials flow (npm-profile's `loginCouch`):
6
+ //
7
+ // 1. PUT the user doc to `<registry>/-/user/org.couchdb.user:<u>` with Basic
8
+ // auth (base64 of `<username>:<password>`) + an `npm-otp` header for 2FA.
9
+ // 2. On 409 (the user already exists — the normal case for a *login*), GET
10
+ // `…?write=true` for the current `_rev`, merge it in, and PUT to
11
+ // `…/-rev/<rev>`.
12
+ // 3. The response carries a bearer `token`; write it to the userconfig
13
+ // `.npmrc` as `//<host>/:_authToken=<token>` (the key `publish`/`whoami`
14
+ // read), then verify with `whoami`.
15
+ //
16
+ // The npm-9+ web-OAuth flow is intentionally NOT implemented — the legacy flow
17
+ // is the Node-free-friendly path and needs no browser. `login` (vs `adduser`)
18
+ // does not send an email.
19
+ //
20
+ // Reference: refs/npm-cli/node_modules/npm-profile/lib/index.js `loginCouch`.
21
+ import { DEFAULT_REGISTRY, registryFor, whoami } from '@gjsify/npm-registry';
22
+ import { loadNpmrc } from '../utils/load-npmrc.js';
23
+ import { writeAuthToken } from '../utils/auth-npmrc.js';
24
+ import { promptLine, promptHidden } from '../utils/prompt.js';
25
+ export const loginCommand = {
26
+ command: 'login',
27
+ description: "Log in to an npm registry (Node-free `npm login`). Prompts for username + password (hidden) and writes the token to ~/.npmrc. Use --otp for 2FA. Web-OAuth flow is not supported — this is npm's legacy credentials flow.",
28
+ builder: (yargs) => yargs
29
+ .option('registry', {
30
+ description: `Registry URL to log in to. Default: ${DEFAULT_REGISTRY} (or a --scope's registry).`,
31
+ type: 'string',
32
+ })
33
+ .option('scope', {
34
+ description: "Associate the login with a scope (e.g. @gjsify) — resolves that scope's registry from .npmrc.",
35
+ type: 'string',
36
+ })
37
+ .option('username', { description: 'Username (prompted if omitted).', type: 'string' })
38
+ .option('otp', { description: 'npm 2FA one-time code (prompted on demand if omitted).', type: 'string' })
39
+ .option('json', { description: 'Emit `{username, registry}` JSON on success.', type: 'boolean' }),
40
+ handler: async (args) => {
41
+ const npmrc = await loadNpmrc(process.cwd());
42
+ const registry = args.registry ??
43
+ process.env.npm_config_registry ??
44
+ (args.scope ? registryFor(args.scope.startsWith('@') ? `${args.scope}/_` : `@${args.scope}/_`, npmrc) : undefined) ??
45
+ DEFAULT_REGISTRY;
46
+ const registryClean = registry.endsWith('/') ? registry : `${registry}/`;
47
+ const username = args.username ?? (await promptLine(`Username: `));
48
+ if (!username) {
49
+ console.error('gjsify login: a username is required.');
50
+ process.exit(1);
51
+ }
52
+ const password = await promptHidden(`Password: `);
53
+ if (!password) {
54
+ console.error('gjsify login: a password is required.');
55
+ process.exit(1);
56
+ }
57
+ // npm legacy auth = HTTP Basic of `<username>:<password>` (the same
58
+ // credentials being logged in with). The password also lives in the
59
+ // PUT body, which is how the registry mints the token.
60
+ const basic = `Basic ${btoa(`${username}:${password}`)}`;
61
+ const couchUrl = `${registryClean}-/user/org.couchdb.user:${encodeURIComponent(username)}`;
62
+ const body = {
63
+ _id: `org.couchdb.user:${username}`,
64
+ name: username,
65
+ password,
66
+ type: 'user',
67
+ roles: [],
68
+ date: undefined, // the registry stamps this; Date is unavailable in some runtimes
69
+ };
70
+ function authHeaders(otp) {
71
+ const h = {
72
+ authorization: basic,
73
+ 'content-type': 'application/json',
74
+ accept: '*/*',
75
+ };
76
+ if (otp)
77
+ h['npm-otp'] = otp;
78
+ return h;
79
+ }
80
+ async function putUser(path, otp) {
81
+ return fetch(`${couchUrl}${path}`, { method: 'PUT', headers: authHeaders(otp), body: JSON.stringify(body) });
82
+ }
83
+ let otp = args.otp;
84
+ let res = await putUser('', otp);
85
+ // 2FA: 401 + www-authenticate "otp" (or a one-time-pass body) → prompt + retry.
86
+ if (res.status === 401 && !otp) {
87
+ const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
88
+ const text = await res.clone().text().catch(() => '');
89
+ if (wwwAuth.includes('otp') || /one-time pass/i.test(text)) {
90
+ otp = await promptLine(`This operation requires a one-time password.\nEnter OTP: `);
91
+ if (!otp) {
92
+ console.error('gjsify login: no OTP entered.');
93
+ process.exit(1);
94
+ }
95
+ res = await putUser('', otp);
96
+ }
97
+ }
98
+ // 409 Conflict: the user already exists (the normal login case). Fetch
99
+ // the current doc for its `_rev`, merge, and PUT to `…/-rev/<rev>`.
100
+ if (res.status === 409) {
101
+ const getRes = await fetch(`${couchUrl}?write=true`, { headers: authHeaders(otp) });
102
+ if (getRes.ok) {
103
+ const existing = (await getRes.json().catch(() => ({})));
104
+ for (const k of Object.keys(existing)) {
105
+ if (!body[k] || k === 'roles')
106
+ body[k] = existing[k];
107
+ }
108
+ if (body._rev)
109
+ res = await putUser(`/-rev/${body._rev}`, otp);
110
+ }
111
+ }
112
+ if (res.status === 400) {
113
+ console.error(`gjsify login: there is no user with the username "${username}" on ${registryClean}.`);
114
+ process.exit(1);
115
+ }
116
+ if (!res.ok) {
117
+ const text = await res.text().catch(() => '<no body>');
118
+ console.error(`gjsify login: ${res.status} ${res.statusText} from ${registryClean}\n${text.slice(0, 400)}`);
119
+ process.exit(1);
120
+ }
121
+ const data = (await res.json().catch(() => ({})));
122
+ if (!data.token) {
123
+ console.error(`gjsify login: the registry accepted the login but returned no token (unexpected response shape). ` +
124
+ `Your registry may require the web-OAuth flow — use \`npm login\`.`);
125
+ process.exit(1);
126
+ }
127
+ const npmrcPath = writeAuthToken(registryClean, data.token);
128
+ // Confirm the freshly-written token authenticates.
129
+ const verifyNpmrc = await loadNpmrc(process.cwd());
130
+ const who = await whoami(registryClean, verifyNpmrc);
131
+ const confirmedName = who.username ?? username;
132
+ if (args.json) {
133
+ process.stdout.write(`${JSON.stringify({ username: confirmedName, registry: registryClean })}\n`);
134
+ }
135
+ else {
136
+ process.stdout.write(`Logged in as ${confirmedName} on ${registryClean}\n(token written to ${npmrcPath})\n`);
137
+ }
138
+ },
139
+ };
@@ -0,0 +1,8 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface LogoutOptions {
3
+ registry?: string;
4
+ scope?: string;
5
+ json?: boolean;
6
+ }
7
+ export declare const logoutCommand: Command<unknown, LogoutOptions>;
8
+ export {};
@@ -0,0 +1,63 @@
1
+ // `gjsify logout [--registry <url>] [--scope @scope]`
2
+ //
3
+ // Node-free `npm logout` — revokes the current bearer token on the registry
4
+ // (best-effort `DELETE /-/user/token/<token>`) and removes the
5
+ // `//<host>/:_authToken=` line from the userconfig `.npmrc`. Completes the auth
6
+ // trio with `gjsify login` / `whoami` / `publish`.
7
+ //
8
+ // Reference: npm's `lib/commands/logout.js` (DELETE the token, then strip the
9
+ // nerf-darted authToken from the config).
10
+ import { DEFAULT_REGISTRY, registryFor, resolveAuthForUrl } from '@gjsify/npm-registry';
11
+ import { loadNpmrc } from '../utils/load-npmrc.js';
12
+ import { removeAuthToken } from '../utils/auth-npmrc.js';
13
+ export const logoutCommand = {
14
+ command: 'logout',
15
+ description: 'Log out of an npm registry (Node-free `npm logout`). Revokes the token on the registry (best-effort) and removes it from ~/.npmrc.',
16
+ builder: (yargs) => yargs
17
+ .option('registry', {
18
+ description: `Registry URL to log out of. Default: ${DEFAULT_REGISTRY} (or a --scope's registry).`,
19
+ type: 'string',
20
+ })
21
+ .option('scope', {
22
+ description: "Log out of a scope's registry (e.g. @gjsify) — resolved from .npmrc.",
23
+ type: 'string',
24
+ })
25
+ .option('json', { description: 'Emit `{registry, revoked, removed}` JSON.', type: 'boolean' }),
26
+ handler: async (args) => {
27
+ const npmrc = await loadNpmrc(process.cwd());
28
+ const registry = args.registry ??
29
+ process.env.npm_config_registry ??
30
+ (args.scope
31
+ ? registryFor(args.scope.startsWith('@') ? `${args.scope}/_` : `@${args.scope}/_`, npmrc)
32
+ : undefined) ??
33
+ DEFAULT_REGISTRY;
34
+ const registryClean = registry.endsWith('/') ? registry : `${registry}/`;
35
+ // Best-effort server-side revoke. The token value is what npm's DELETE
36
+ // endpoint expects in the path; if there's no token we just strip locally.
37
+ const auth = resolveAuthForUrl(`${registryClean}-/whoami`, npmrc);
38
+ let revoked = false;
39
+ if (auth?.startsWith('Bearer ')) {
40
+ const token = auth.slice('Bearer '.length);
41
+ try {
42
+ const res = await fetch(`${registryClean}-/user/token/${encodeURIComponent(token)}`, {
43
+ method: 'DELETE',
44
+ headers: { authorization: auth, accept: '*/*' },
45
+ });
46
+ revoked = res.ok;
47
+ }
48
+ catch {
49
+ /* offline / registry down — fall through to local removal */
50
+ }
51
+ }
52
+ const { path, removed } = removeAuthToken(registryClean);
53
+ if (args.json) {
54
+ process.stdout.write(`${JSON.stringify({ registry: registryClean, revoked, removed })}\n`);
55
+ }
56
+ else if (removed) {
57
+ process.stdout.write(`Logged out of ${registryClean}${revoked ? ' (token revoked)' : ''}\n(removed from ${path})\n`);
58
+ }
59
+ else {
60
+ process.stdout.write(`No token for ${registryClean} found in ${path} — nothing to remove.\n`);
61
+ }
62
+ },
63
+ };
@@ -404,13 +404,13 @@ export const publishCommand = {
404
404
  process.stdout.write(`= ${packed.name}@${packed.version} (already published, tolerated)\n`);
405
405
  return;
406
406
  }
407
- // 404 diagnostic — token-auth only. npm returns 404 for both a
408
- // dead `_authToken` and a genuinely-missing package; `/-/whoami`
409
- // disambiguates. OIDC has its own clear error surfaces (handled in
410
- // the OIDC catch block above) and `--otp` flows take a different
411
- // 401 path, so the diagnostic only kicks in for the plain
412
- // token-auth PUT signature.
413
- if (res.status === 404 && authMode === 'token' && !otp) {
407
+ // 404 diagnostic — token-auth (with or without --otp). npm returns 404
408
+ // for a dead `_authToken`, a genuinely-missing package, AND transiently
409
+ // while a brand-new scoped package is being provisioned; `/-/whoami`
410
+ // disambiguates a dead token from a live one. OIDC has its own clear
411
+ // error surfaces (handled in the OIDC catch block above), so the
412
+ // diagnostic only kicks in for the token-auth PUT signature.
413
+ if (res.status === 404 && authMode === 'token') {
414
414
  if (is404DiagnosticCandidate(text)) {
415
415
  const diag = await diagnose404({
416
416
  packageName: packed.name,
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import yargs from 'yargs';
6
6
  import { hideBin } from 'yargs/helpers';
7
- import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, } from './commands/index.js';
7
+ import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, } from './commands/index.js';
8
8
  import { APP_NAME } from './constants.js';
9
9
  // Detect which runtime is executing the CLI (GJS or Node.js).
10
10
  // GJS MUST be checked first because @gjsify/process sets
@@ -80,6 +80,8 @@ await cli
80
80
  .command(pack.command, pack.description, pack.builder, pack.handler)
81
81
  .command(publish.command, publish.description, publish.builder, publish.handler)
82
82
  .command(whoami.command, whoami.description, whoami.builder, whoami.handler)
83
+ .command(login.command, login.description, login.builder, login.handler)
84
+ .command(logout.command, logout.description, logout.builder, logout.handler)
83
85
  .command(selfUpdate.command, selfUpdate.description, selfUpdate.builder, selfUpdate.handler)
84
86
  .command(generateInstaller.command, generateInstaller.description, generateInstaller.builder, generateInstaller.handler)
85
87
  .command(uninstall.command, uninstall.description, uninstall.builder, uninstall.handler)
@@ -0,0 +1,22 @@
1
+ /** The `.npmrc` file `gjsify login` writes to (matches `load-npmrc.ts`'s userconfig source). */
2
+ export declare function userconfigNpmrcPath(): string;
3
+ /**
4
+ * The npm "nerf-dart" key for a registry: the URL with the protocol removed,
5
+ * keeping the host + path with a trailing slash — `//registry.npmjs.org/`.
6
+ */
7
+ export declare function nerfDart(registry: string): string;
8
+ /** The full `_authToken` config key for a registry. */
9
+ export declare function authTokenKey(registry: string): string;
10
+ /**
11
+ * Upsert `<nerf-dart>:_authToken=<token>` in the userconfig `.npmrc`, preserving
12
+ * all other lines. Returns the file path written.
13
+ */
14
+ export declare function writeAuthToken(registry: string, token: string): string;
15
+ /**
16
+ * Remove the `<nerf-dart>:_authToken=` line for a registry from the userconfig
17
+ * `.npmrc`. Returns `{ path, removed }` — `removed` is false if no line matched.
18
+ */
19
+ export declare function removeAuthToken(registry: string): {
20
+ path: string;
21
+ removed: boolean;
22
+ };
@@ -0,0 +1,89 @@
1
+ // Write/remove npm auth tokens in the user's `.npmrc` — the write side of
2
+ // `load-npmrc.ts` (which only reads). Used by `gjsify login` / `gjsify logout`.
3
+ //
4
+ // npm stores the token under a "nerf-dart" key: the registry URL with the
5
+ // protocol stripped, e.g. `//registry.npmjs.org/:_authToken=<token>`. We upsert
6
+ // exactly that line in the userconfig file (`$NPM_CONFIG_USERCONFIG` or
7
+ // `~/.npmrc`), preserving every other line, and chmod it to 0600 (it holds a
8
+ // credential).
9
+ import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join, dirname } from 'node:path';
12
+ /** The `.npmrc` file `gjsify login` writes to (matches `load-npmrc.ts`'s userconfig source). */
13
+ export function userconfigNpmrcPath() {
14
+ return process.env.NPM_CONFIG_USERCONFIG || join(homedir(), '.npmrc');
15
+ }
16
+ /**
17
+ * The npm "nerf-dart" key for a registry: the URL with the protocol removed,
18
+ * keeping the host + path with a trailing slash — `//registry.npmjs.org/`.
19
+ */
20
+ export function nerfDart(registry) {
21
+ const u = new URL(registry);
22
+ const path = u.pathname.endsWith('/') ? u.pathname : `${u.pathname}/`;
23
+ return `//${u.host}${path}`;
24
+ }
25
+ /** The full `_authToken` config key for a registry. */
26
+ export function authTokenKey(registry) {
27
+ return `${nerfDart(registry)}:_authToken`;
28
+ }
29
+ function readUserconfig(path) {
30
+ return existsSync(path) ? readFileSync(path, 'utf-8') : '';
31
+ }
32
+ function writeUserconfig(path, content) {
33
+ const dir = dirname(path);
34
+ if (!existsSync(dir))
35
+ mkdirSync(dir, { recursive: true });
36
+ writeFileSync(path, content, 'utf-8');
37
+ // Credential file — restrict to the owner (best-effort; ignored on Windows).
38
+ try {
39
+ chmodSync(path, 0o600);
40
+ }
41
+ catch {
42
+ /* non-POSIX filesystem */
43
+ }
44
+ }
45
+ /**
46
+ * Upsert `<nerf-dart>:_authToken=<token>` in the userconfig `.npmrc`, preserving
47
+ * all other lines. Returns the file path written.
48
+ */
49
+ export function writeAuthToken(registry, token) {
50
+ const path = userconfigNpmrcPath();
51
+ const key = authTokenKey(registry);
52
+ const line = `${key}=${token}`;
53
+ const existing = readUserconfig(path);
54
+ const lines = existing.length ? existing.split('\n') : [];
55
+ let replaced = false;
56
+ const next = lines.map((l) => {
57
+ // Match the key at the start of the line (ignore `${...}` placeholder
58
+ // values too — we always overwrite with the concrete token).
59
+ if (l.replace(/\s+/g, '').startsWith(`${key}=`)) {
60
+ replaced = true;
61
+ return line;
62
+ }
63
+ return l;
64
+ });
65
+ // Strip trailing blank lines so we don't accumulate them across rewrites.
66
+ while (next.length && next[next.length - 1].trim() === '')
67
+ next.pop();
68
+ if (!replaced)
69
+ next.push(line);
70
+ writeUserconfig(path, next.join('\n') + '\n');
71
+ return path;
72
+ }
73
+ /**
74
+ * Remove the `<nerf-dart>:_authToken=` line for a registry from the userconfig
75
+ * `.npmrc`. Returns `{ path, removed }` — `removed` is false if no line matched.
76
+ */
77
+ export function removeAuthToken(registry) {
78
+ const path = userconfigNpmrcPath();
79
+ const key = authTokenKey(registry);
80
+ const existing = readUserconfig(path);
81
+ if (!existing)
82
+ return { path, removed: false };
83
+ const lines = existing.split('\n');
84
+ const next = lines.filter((l) => !l.replace(/\s+/g, '').startsWith(`${key}=`));
85
+ const removed = next.length !== lines.length;
86
+ if (removed)
87
+ writeUserconfig(path, next.join('\n'));
88
+ return { path, removed };
89
+ }