@gjsify/cli 0.15.1 → 0.16.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.
@@ -8,6 +8,7 @@ import { dirname, extname, join } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
10
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
11
+ import { inputSourceDirs, isOutdirInsideSource, libraryOutputLeakError } from '../utils/library-output.js';
11
12
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
12
13
  /**
13
14
  * `true` when `path` points at a location that's unsafe to use as a build
@@ -83,6 +84,19 @@ export class BuildAction {
83
84
  const { verbose, library, typescript, exclude, aliases } = this.configData;
84
85
  const lib = library ?? {};
85
86
  const userBundler = normalizeBundlerOptions(this.configData);
87
+ // Refuse to emit the compiled tree into the input source directory.
88
+ // The outdir is derived below from `dirname(module ?? main)`; when a
89
+ // package points `main`/`module` at a source entry (`src/index.ts`)
90
+ // that derivation is `src/`, so preserve-modules would write `.js`
91
+ // duplicates + a `_virtual/` dir + a nested `src/<pkg>/src` tree next
92
+ // to the sources. An explicit `--outdir` is the escape hatch and is
93
+ // trusted; only the DERIVED default is guarded.
94
+ const cwd = process.cwd();
95
+ const sourceDirs = inputSourceDirs(userBundler.input, cwd);
96
+ const assertDerivedOutdirSafe = (outdir) => {
97
+ if (outdir && isOutdirInsideSource(outdir, sourceDirs, cwd))
98
+ throw libraryOutputLeakError(outdir);
99
+ };
86
100
  const moduleOutdir = lib.module ? dirname(lib.module) : undefined;
87
101
  const mainOutdir = lib.main ? dirname(lib.main) : undefined;
88
102
  const moduleOutExt = lib.module ? extname(lib.module) : '.js';
@@ -92,6 +106,10 @@ export class BuildAction {
92
106
  const pnpPlugins = pnp ? [pnp] : [];
93
107
  const results = [];
94
108
  if (multipleBuilds) {
109
+ // Both outdirs are derived from module/main — always guard them
110
+ // (`--outdir` is not consulted on the multi-output path).
111
+ assertDerivedOutdirSafe(moduleOutdir);
112
+ assertDerivedOutdirSafe(mainOutdir);
95
113
  const moduleFormat = moduleOutdir.includes('/cjs') || moduleOutExt === '.cjs' ? 'cjs' : 'esm';
96
114
  results.push(await runOneLibraryBuild({
97
115
  pluginOpts: {
@@ -122,9 +140,14 @@ export class BuildAction {
122
140
  }));
123
141
  }
124
142
  else {
143
+ const explicitOutdir = userBundler.output?.dir;
125
144
  const outfilePath = userBundler.output?.file ?? lib.module ?? lib.main;
126
145
  const outExt = outfilePath ? extname(outfilePath) : '.js';
127
- const outdir = userBundler.output?.dir ?? (outfilePath ? dirname(outfilePath) : undefined);
146
+ const outdir = explicitOutdir ?? (outfilePath ? dirname(outfilePath) : undefined);
147
+ // Only the DERIVED default is a footgun; a user-supplied --outdir
148
+ // is trusted (the documented escape hatch).
149
+ if (explicitOutdir === undefined)
150
+ assertDerivedOutdirSafe(outdir);
128
151
  const format = userBundler.output?.format ??
129
152
  (outdir?.includes('/cjs') || outExt === '.cjs' ? 'cjs' : 'esm');
130
153
  results.push(await runOneLibraryBuild({
package/lib/cli-app.js CHANGED
@@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import yargs from 'yargs';
22
- 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, trustCommand as trust, 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, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
22
+ 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, trustCommand as trust, onboardCommand as onboard, 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, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
23
23
  import { APP_NAME } from './constants.js';
24
24
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
25
25
  import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
@@ -103,6 +103,7 @@ export async function runCli(argv) {
103
103
  .command(login.command, login.description, login.builder, login.handler)
104
104
  .command(logout.command, logout.description, logout.builder, logout.handler)
105
105
  .command(trust.command, trust.description, trust.builder, trust.handler)
106
+ .command(onboard.command, onboard.description, onboard.builder, onboard.handler)
106
107
  .command(selfUpdate.command, selfUpdate.description, selfUpdate.builder, selfUpdate.handler)
107
108
  .command(generateInstaller.command, generateInstaller.description, generateInstaller.builder, generateInstaller.handler)
108
109
  .command(uninstall.command, uninstall.description, uninstall.builder, uninstall.handler)
@@ -20,6 +20,7 @@ export * from './whoami.js';
20
20
  export * from './login.js';
21
21
  export * from './logout.js';
22
22
  export * from './trust.js';
23
+ export * from './onboard.js';
23
24
  export * from './self-update.js';
24
25
  export * from './generate-installer.js';
25
26
  export * from './uninstall.js';
@@ -20,6 +20,7 @@ export * from './whoami.js';
20
20
  export * from './login.js';
21
21
  export * from './logout.js';
22
22
  export * from './trust.js';
23
+ export * from './onboard.js';
23
24
  export * from './self-update.js';
24
25
  export * from './generate-installer.js';
25
26
  export * from './uninstall.js';
@@ -6,5 +6,34 @@ interface LoginOptions {
6
6
  otp?: string;
7
7
  json?: boolean;
8
8
  }
9
+ /** Options for the reusable {@link runLogin} flow. */
10
+ export interface RunLoginOptions {
11
+ registry?: string;
12
+ scope?: string;
13
+ username?: string;
14
+ otp?: string;
15
+ /** Directory whose `.npmrc` seeds scope/registry resolution. Default: cwd. */
16
+ cwd?: string;
17
+ }
18
+ /** Successful login result. */
19
+ export interface LoginResult {
20
+ username: string;
21
+ /** Registry URL (with trailing slash). */
22
+ registry: string;
23
+ /** Path of the `.npmrc` the token was written to. */
24
+ npmrcPath: string;
25
+ }
26
+ /** Thrown by {@link runLogin} on any recoverable login failure (bad creds, no token, …). */
27
+ export declare class LoginError extends Error {
28
+ constructor(message: string);
29
+ }
9
30
  export declare const loginCommand: Command<unknown, LoginOptions>;
31
+ /**
32
+ * The `gjsify login` core, extracted so `gjsify onboard` can authenticate the
33
+ * user when the whoami gate fails. Prompts for username + password (unless
34
+ * provided), PUTs the CouchDB user doc, writes the token to the userconfig
35
+ * `.npmrc`, and verifies it with `whoami`. Throws {@link LoginError} on any
36
+ * recoverable failure (the command handler / onboard present it).
37
+ */
38
+ export declare function runLogin(opts: RunLoginOptions): Promise<LoginResult>;
10
39
  export {};
@@ -22,6 +22,13 @@ import { DEFAULT_REGISTRY, registryFor, whoami } from '@gjsify/npm-registry';
22
22
  import { loadNpmrc } from '../utils/load-npmrc.js';
23
23
  import { writeAuthToken } from '../utils/auth-npmrc.js';
24
24
  import { promptCredentials, promptLine } from '../utils/prompt.js';
25
+ /** Thrown by {@link runLogin} on any recoverable login failure (bad creds, no token, …). */
26
+ export class LoginError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'LoginError';
30
+ }
31
+ }
25
32
  export const loginCommand = {
26
33
  command: 'login',
27
34
  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.",
@@ -38,105 +45,124 @@ export const loginCommand = {
38
45
  .option('otp', { description: 'npm 2FA one-time code (prompted on demand if omitted).', type: 'string' })
39
46
  .option('json', { description: 'Emit `{username, registry}` JSON on success.', type: 'boolean' }),
40
47
  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
- // Read username (visible) + password (masked) in a single raw-mode TTY
48
- // session — robust against cooked line-discipline state + the shared
49
- // stdin resume/pause race that intermittently broke the old two-prompt
50
- // sequence (Enter showing `^M`, or an empty username).
51
- const { username, password } = await promptCredentials(args.username);
52
- if (!username) {
53
- console.error('gjsify login: a username is required.');
54
- process.exit(1);
55
- }
56
- if (!password) {
57
- console.error('gjsify login: a password is required.');
58
- process.exit(1);
59
- }
60
- // npm legacy auth = HTTP Basic of `<username>:<password>` (the same
61
- // credentials being logged in with). The password also lives in the
62
- // PUT body, which is how the registry mints the token.
63
- const basic = `Basic ${btoa(`${username}:${password}`)}`;
64
- const couchUrl = `${registryClean}-/user/org.couchdb.user:${encodeURIComponent(username)}`;
65
- const body = {
66
- _id: `org.couchdb.user:${username}`,
67
- name: username,
68
- password,
69
- type: 'user',
70
- roles: [],
71
- date: undefined, // the registry stamps this; Date is unavailable in some runtimes
72
- };
73
- function authHeaders(otp) {
74
- const h = {
75
- authorization: basic,
76
- 'content-type': 'application/json',
77
- accept: '*/*',
78
- };
79
- if (otp)
80
- h['npm-otp'] = otp;
81
- return h;
82
- }
83
- async function putUser(path, otp) {
84
- return fetch(`${couchUrl}${path}`, { method: 'PUT', headers: authHeaders(otp), body: JSON.stringify(body) });
85
- }
86
- let otp = args.otp;
87
- let res = await putUser('', otp);
88
- // 2FA: 401 + www-authenticate "otp" (or a one-time-pass body) → prompt + retry.
89
- if (res.status === 401 && !otp) {
90
- const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
91
- const text = await res.clone().text().catch(() => '');
92
- if (wwwAuth.includes('otp') || /one-time pass/i.test(text)) {
93
- otp = await promptLine(`This operation requires a one-time password.\nEnter OTP: `);
94
- if (!otp) {
95
- console.error('gjsify login: no OTP entered.');
96
- process.exit(1);
97
- }
98
- res = await putUser('', otp);
48
+ try {
49
+ const { username, registry, npmrcPath } = await runLogin({
50
+ registry: args.registry,
51
+ scope: args.scope,
52
+ username: args.username,
53
+ otp: args.otp,
54
+ });
55
+ if (args.json) {
56
+ process.stdout.write(`${JSON.stringify({ username, registry })}\n`);
99
57
  }
100
- }
101
- // 409 Conflict: the user already exists (the normal login case). Fetch
102
- // the current doc for its `_rev`, merge, and PUT to `…/-rev/<rev>`.
103
- if (res.status === 409) {
104
- const getRes = await fetch(`${couchUrl}?write=true`, { headers: authHeaders(otp) });
105
- if (getRes.ok) {
106
- const existing = (await getRes.json().catch(() => ({})));
107
- for (const k of Object.keys(existing)) {
108
- if (!body[k] || k === 'roles')
109
- body[k] = existing[k];
110
- }
111
- if (body._rev)
112
- res = await putUser(`/-rev/${body._rev}`, otp);
58
+ else {
59
+ process.stdout.write(`Logged in as ${username} on ${registry}\n(token written to ${npmrcPath})\n`);
113
60
  }
114
61
  }
115
- if (res.status === 400) {
116
- console.error(`gjsify login: there is no user with the username "${username}" on ${registryClean}.`);
117
- process.exit(1);
118
- }
119
- if (!res.ok) {
120
- const text = await res.text().catch(() => '<no body>');
121
- console.error(`gjsify login: ${res.status} ${res.statusText} from ${registryClean}\n${text.slice(0, 400)}`);
122
- process.exit(1);
123
- }
124
- const data = (await res.json().catch(() => ({})));
125
- if (!data.token) {
126
- console.error(`gjsify login: the registry accepted the login but returned no token (unexpected response shape). ` +
127
- `Your registry may require the web-OAuth flow — use \`npm login\`.`);
128
- process.exit(1);
129
- }
130
- const npmrcPath = writeAuthToken(registryClean, data.token);
131
- // Confirm the freshly-written token authenticates.
132
- const verifyNpmrc = await loadNpmrc(process.cwd());
133
- const who = await whoami(registryClean, verifyNpmrc);
134
- const confirmedName = who.username ?? username;
135
- if (args.json) {
136
- process.stdout.write(`${JSON.stringify({ username: confirmedName, registry: registryClean })}\n`);
137
- }
138
- else {
139
- process.stdout.write(`Logged in as ${confirmedName} on ${registryClean}\n(token written to ${npmrcPath})\n`);
62
+ catch (err) {
63
+ if (err instanceof LoginError) {
64
+ console.error(err.message);
65
+ process.exit(1);
66
+ }
67
+ throw err;
140
68
  }
141
69
  },
142
70
  };
71
+ /**
72
+ * The `gjsify login` core, extracted so `gjsify onboard` can authenticate the
73
+ * user when the whoami gate fails. Prompts for username + password (unless
74
+ * provided), PUTs the CouchDB user doc, writes the token to the userconfig
75
+ * `.npmrc`, and verifies it with `whoami`. Throws {@link LoginError} on any
76
+ * recoverable failure (the command handler / onboard present it).
77
+ */
78
+ export async function runLogin(opts) {
79
+ const npmrc = await loadNpmrc(opts.cwd ?? process.cwd());
80
+ const registry = opts.registry ??
81
+ process.env.npm_config_registry ??
82
+ (opts.scope
83
+ ? registryFor(opts.scope.startsWith('@') ? `${opts.scope}/_` : `@${opts.scope}/_`, npmrc)
84
+ : undefined) ??
85
+ DEFAULT_REGISTRY;
86
+ const registryClean = registry.endsWith('/') ? registry : `${registry}/`;
87
+ // Read username (visible) + password (masked) in a single raw-mode TTY
88
+ // session — robust against cooked line-discipline state + the shared stdin
89
+ // resume/pause race.
90
+ const { username, password } = await promptCredentials(opts.username);
91
+ if (!username)
92
+ throw new LoginError('gjsify login: a username is required.');
93
+ if (!password)
94
+ throw new LoginError('gjsify login: a password is required.');
95
+ // npm legacy auth = HTTP Basic of `<username>:<password>`. The password also
96
+ // lives in the PUT body, which is how the registry mints the token.
97
+ const basic = `Basic ${btoa(`${username}:${password}`)}`;
98
+ const couchUrl = `${registryClean}-/user/org.couchdb.user:${encodeURIComponent(username)}`;
99
+ const body = {
100
+ _id: `org.couchdb.user:${username}`,
101
+ name: username,
102
+ password,
103
+ type: 'user',
104
+ roles: [],
105
+ date: undefined, // the registry stamps this; Date is unavailable in some runtimes
106
+ };
107
+ function authHeaders(otp) {
108
+ const h = {
109
+ authorization: basic,
110
+ 'content-type': 'application/json',
111
+ accept: '*/*',
112
+ };
113
+ if (otp)
114
+ h['npm-otp'] = otp;
115
+ return h;
116
+ }
117
+ async function putUser(path, otp) {
118
+ return fetch(`${couchUrl}${path}`, { method: 'PUT', headers: authHeaders(otp), body: JSON.stringify(body) });
119
+ }
120
+ let otp = opts.otp;
121
+ let res = await putUser('', otp);
122
+ // 2FA: 401 + www-authenticate "otp" (or a one-time-pass body) → prompt + retry.
123
+ if (res.status === 401 && !otp) {
124
+ const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
125
+ const text = await res
126
+ .clone()
127
+ .text()
128
+ .catch(() => '');
129
+ if (wwwAuth.includes('otp') || /one-time pass/i.test(text)) {
130
+ otp = await promptLine(`This operation requires a one-time password.\nEnter OTP: `);
131
+ if (!otp)
132
+ throw new LoginError('gjsify login: no OTP entered.');
133
+ res = await putUser('', otp);
134
+ }
135
+ }
136
+ // 409 Conflict: the user already exists (the normal login case). Fetch the
137
+ // current doc for its `_rev`, merge, and PUT to `…/-rev/<rev>`.
138
+ if (res.status === 409) {
139
+ const getRes = await fetch(`${couchUrl}?write=true`, { headers: authHeaders(otp) });
140
+ if (getRes.ok) {
141
+ const existing = (await getRes.json().catch(() => ({})));
142
+ for (const k of Object.keys(existing)) {
143
+ if (!body[k] || k === 'roles')
144
+ body[k] = existing[k];
145
+ }
146
+ if (body._rev)
147
+ res = await putUser(`/-rev/${body._rev}`, otp);
148
+ }
149
+ }
150
+ if (res.status === 400) {
151
+ throw new LoginError(`gjsify login: there is no user with the username "${username}" on ${registryClean}.`);
152
+ }
153
+ if (!res.ok) {
154
+ const text = await res.text().catch(() => '<no body>');
155
+ throw new LoginError(`gjsify login: ${res.status} ${res.statusText} from ${registryClean}\n${text.slice(0, 400)}`);
156
+ }
157
+ const data = (await res.json().catch(() => ({})));
158
+ if (!data.token) {
159
+ throw new LoginError(`gjsify login: the registry accepted the login but returned no token (unexpected response shape). ` +
160
+ `Your registry may require the web-OAuth flow — use \`npm login\`.`);
161
+ }
162
+ const npmrcPath = writeAuthToken(registryClean, data.token);
163
+ // Confirm the freshly-written token authenticates.
164
+ const verifyNpmrc = await loadNpmrc(opts.cwd ?? process.cwd());
165
+ const who = await whoami(registryClean, verifyNpmrc);
166
+ const confirmedName = who.username ?? username;
167
+ return { username: confirmedName, registry: registryClean, npmrcPath };
168
+ }
@@ -0,0 +1,14 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface OnboardOptions {
3
+ repository?: string;
4
+ workflow: string;
5
+ environment?: string;
6
+ registry?: string;
7
+ otp?: string;
8
+ concurrency: number;
9
+ 'dry-run'?: boolean;
10
+ json?: boolean;
11
+ yes?: boolean;
12
+ }
13
+ export declare const onboardCommand: Command<unknown, OnboardOptions>;
14
+ export {};