@gjsify/cli 0.7.5 → 0.9.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.
@@ -1,81 +1,118 @@
1
- // Minimal interactive prompts for `gjsify login` — a visible line prompt and a
2
- // hidden (no-echo) password prompt. Cross-runtime: `process.stdin.setRawMode`
3
- // is provided by Node and by `@gjsify/process` (terminal-native) under GJS.
1
+ // Interactive prompts for `gjsify login` / `gjsify trust`.
4
2
  //
5
- // Non-TTY stdin (piped input, CI) is supported: both helpers read a single line
6
- // without raw-mode masking, so `printf 'user\npass\n' | gjsify login` works for
7
- // automation.
8
- const CTRL_C = String.fromCharCode(3); // ETX (Ctrl-C)
9
- const DEL = String.fromCharCode(127); // DEL (Backspace on most terminals)
10
- const BACKSPACE = String.fromCharCode(8); // BS
11
- /** Print a question and read one line from stdin (visible). */
12
- export async function promptLine(question) {
13
- process.stdout.write(question);
14
- return readLine();
3
+ // On a TTY every prompt runs inside a single RAW-mode session (`runRawSession`)
4
+ // that reads key-by-key with manual echo and a guaranteed cooked-mode restore
5
+ // (try/finally). This deliberately does NOT rely on the terminal's cooked
6
+ // line-discipline nor on the shared `process.stdin` resume/pause cycle:
7
+ // - The old visible prompt read a cooked line via `process.stdin` 'data'
8
+ // events. With the line discipline in an unexpected state (e.g. ICRNL off
9
+ // after a prior raw prompt) Enter arrived as a bare `\r` that never
10
+ // terminated the cooked line — the prompt hung showing `name^M`. The
11
+ // resume/pause churn on the shared stdin singleton between two sequential
12
+ // prompts also intermittently dropped the line or resolved it empty.
13
+ // - Reading raw + handling `\r`/`\n` ourselves removes both failure modes.
14
+ //
15
+ // Raw mode (via @gjsify/process → terminal-native) now also clears ISIG, so
16
+ // Ctrl-C is delivered as a `\x03` keystroke we handle (restore + exit) instead
17
+ // of a SIGINT that would kill the process leaving the terminal in raw mode.
18
+ //
19
+ // Non-TTY stdin (piped input, CI) keeps a plain line read so
20
+ // `printf 'user\npass\n' | gjsify login` still works.
21
+ const CTRL_C = '\x03'; // ETX (Ctrl-C)
22
+ const DEL = '\x7f'; // DEL (Backspace on most terminals)
23
+ const BACKSPACE = '\x08'; // BS
24
+ function isTtyStdin() {
25
+ const stdin = process.stdin;
26
+ return Boolean(stdin.isTTY) && typeof stdin.setRawMode === 'function';
15
27
  }
16
28
  /**
17
- * Print a question and read one line WITHOUT echoing it (passwords). Uses raw
18
- * mode + manual key handling on a TTY; falls back to a plain line read when
19
- * stdin is not a TTY (piped). Ctrl-C aborts the process.
29
+ * Pure key handler for the raw-mode line editor — the single source of truth
30
+ * for how a keystroke updates the buffer + echo. Kept pure (no I/O) so it is
31
+ * unit-testable: this is what makes Enter (`\r` OR `\n`) reliably submit, masks
32
+ * passwords, and treats Ctrl-C as an interrupt rather than text.
20
33
  */
21
- export async function promptHidden(question) {
22
- process.stdout.write(question);
23
- const stdin = process.stdin;
24
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
25
- // Non-interactive: read a line as-is (no masking possible/needed).
26
- return readLine();
34
+ export function applyKey(buf, ch, mask) {
35
+ if (ch === '\r' || ch === '\n') {
36
+ return { buf, echo: '\n', done: true, interrupt: false };
27
37
  }
28
- return new Promise((resolve) => {
29
- let buf = '';
30
- stdin.setRawMode(true);
31
- stdin.resume();
32
- // setEncoding makes 'data' emit decoded STRINGS — including under GJS,
33
- // where @gjsify/process honours it via StringDecoder (was a no-op stub;
34
- // bytes broke `ch === '\r'` so Enter never submitted). So we can iterate
35
- // characters directly here; do NOT reintroduce a byte-normalising shim.
36
- stdin.setEncoding('utf-8');
37
- const onData = (chunk) => {
38
- for (const ch of chunk) {
39
- if (ch === '\r' || ch === '\n') {
40
- cleanup();
41
- process.stdout.write('\n');
42
- resolve(buf);
43
- return;
44
- }
45
- else if (ch === CTRL_C) {
46
- cleanup();
47
- process.stdout.write('\n');
48
- process.exit(130);
49
- }
50
- else if (ch === DEL || ch === BACKSPACE) {
51
- if (buf.length > 0) {
52
- buf = buf.slice(0, -1);
53
- process.stdout.write('\b \b');
54
- }
55
- }
56
- else if (ch >= ' ') {
57
- buf += ch;
58
- process.stdout.write('*');
59
- }
60
- }
61
- };
62
- const cleanup = () => {
38
+ if (ch === CTRL_C) {
39
+ return { buf, echo: '\n', done: false, interrupt: true };
40
+ }
41
+ if (ch === DEL || ch === BACKSPACE) {
42
+ if (buf.length > 0) {
43
+ return { buf: buf.slice(0, -1), echo: '\b \b', done: false, interrupt: false };
44
+ }
45
+ return { buf, echo: '', done: false, interrupt: false };
46
+ }
47
+ if (ch >= ' ') {
48
+ return { buf: buf + ch, echo: mask ? '*' : ch, done: false, interrupt: false };
49
+ }
50
+ // Other control characters (arrows, tabs, escapes) are ignored.
51
+ return { buf, echo: '', done: false, interrupt: false };
52
+ }
53
+ /**
54
+ * Open ONE raw-mode stdin session, run `fn` (which may read several lines via
55
+ * the supplied reader), and always restore cooked mode afterwards. Keeping a
56
+ * single session for the whole credential exchange avoids per-prompt
57
+ * resume/pause races on the shared stdin singleton.
58
+ */
59
+ async function runRawSession(fn) {
60
+ const stdin = process.stdin;
61
+ const out = process.stdout;
62
+ let pending = null;
63
+ const cleanup = () => {
64
+ stdin.removeListener('data', onData);
65
+ try {
63
66
  stdin.setRawMode(false);
64
- stdin.removeListener('data', onData);
67
+ }
68
+ catch {
69
+ /* not a TTY any more */
70
+ }
71
+ if (typeof stdin.pause === 'function')
65
72
  stdin.pause();
66
- };
67
- stdin.on('data', onData);
73
+ };
74
+ const onData = (chunk) => {
75
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
76
+ for (const ch of text) {
77
+ if (!pending)
78
+ continue; // ignore type-ahead between prompts
79
+ const r = applyKey(pending.buf, ch, pending.mask);
80
+ pending.buf = r.buf;
81
+ if (r.echo)
82
+ out.write(r.echo);
83
+ if (r.interrupt) {
84
+ cleanup();
85
+ process.exit(130);
86
+ }
87
+ if (r.done) {
88
+ const { resolve } = pending;
89
+ pending = null;
90
+ resolve(r.buf);
91
+ }
92
+ }
93
+ };
94
+ stdin.setRawMode(true);
95
+ if (typeof stdin.resume === 'function')
96
+ stdin.resume();
97
+ stdin.setEncoding('utf-8');
98
+ stdin.on('data', onData);
99
+ const read = (question, mask) => new Promise((resolve) => {
100
+ out.write(question);
101
+ pending = { mask, buf: '', resolve };
68
102
  });
103
+ try {
104
+ return await fn(read);
105
+ }
106
+ finally {
107
+ cleanup();
108
+ }
69
109
  }
70
- /** Read a single line from stdin (shared by both prompts on non-TTY). */
110
+ /** Read a single line from non-TTY stdin (piped). Accepts CR, LF, or CRLF. */
71
111
  function readLine() {
72
112
  return new Promise((resolve) => {
73
113
  let buf = '';
74
114
  const onData = (chunk) => {
75
115
  buf += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
76
- // Accept CR, LF, or CRLF as the line terminator. A bare CR (`\r`)
77
- // is what some terminals / the GJS terminal-native stdin deliver on
78
- // Enter — keying only on `\n` made the prompt hang forever there.
79
116
  const m = buf.search(/[\r\n]/);
80
117
  if (m >= 0) {
81
118
  cleanup();
@@ -97,3 +134,42 @@ function readLine() {
97
134
  process.stdin.once('end', onEnd);
98
135
  });
99
136
  }
137
+ /** Print a question and read one visible line. */
138
+ export async function promptLine(question) {
139
+ if (!isTtyStdin()) {
140
+ process.stdout.write(question);
141
+ return readLine();
142
+ }
143
+ return runRawSession((read) => read(question, false));
144
+ }
145
+ /** Print a question and read one line WITHOUT echoing it (passwords). */
146
+ export async function promptHidden(question) {
147
+ if (!isTtyStdin()) {
148
+ process.stdout.write(question);
149
+ return readLine();
150
+ }
151
+ return runRawSession((read) => read(question, true));
152
+ }
153
+ /**
154
+ * Read npm credentials. On a TTY both the visible username and the masked
155
+ * password are read in ONE raw session (no cooked-line dependency, no
156
+ * inter-prompt resume/pause race). `providedUsername` (from `--username`) skips
157
+ * the username prompt.
158
+ */
159
+ export async function promptCredentials(providedUsername) {
160
+ if (!isTtyStdin()) {
161
+ let username = providedUsername;
162
+ if (!username) {
163
+ process.stdout.write('Username: ');
164
+ username = await readLine();
165
+ }
166
+ process.stdout.write('Password: ');
167
+ const password = await readLine();
168
+ return { username, password };
169
+ }
170
+ return runRawSession(async (read) => {
171
+ const username = providedUsername ?? (await read('Username: ', false));
172
+ const password = await read('Password: ', true);
173
+ return { username, password };
174
+ });
175
+ }
@@ -7,6 +7,26 @@ export interface PluginByName {
7
7
  /** Options forwarded to the plugin factory. */
8
8
  options?: unknown;
9
9
  }
10
+ /**
11
+ * Strategy for turning a resolved plugin file path into its module namespace.
12
+ *
13
+ * On Node this is a plain dynamic `import()`. Under GJS it cannot be — GJS's
14
+ * native ESM loader does not follow npm `package.json#exports` subpath maps
15
+ * for bare specifiers, so importing a plugin whose source does
16
+ * `import … from '@scope/pkg/internal-subpath'` throws
17
+ * `Module not found: @scope/pkg/internal-subpath` even when the file is on
18
+ * disk. The CLI injects a GJS strategy that first bundles the plugin to a
19
+ * single self-contained ESM file (Rolldown resolves the exports map at
20
+ * bundle time), then imports that. See `BuildAction.loadPluginViaGjsBundle`.
21
+ */
22
+ export type LoadPluginModule = (resolvedPath: string, pluginName: string) => Promise<Record<string, unknown>>;
23
+ export interface ResolveUserPluginsOptions {
24
+ /**
25
+ * Override how a resolved plugin path is turned into its module
26
+ * namespace. Defaults to a direct dynamic `import()` (correct on Node).
27
+ */
28
+ loadModule?: LoadPluginModule;
29
+ }
10
30
  /** Type-guard: a `PluginByName` shape rather than a Rolldown plugin object. */
11
31
  export declare function isPluginByName(value: unknown): value is PluginByName;
12
32
  /**
@@ -18,4 +38,4 @@ export declare function isPluginByName(value: unknown): value is PluginByName;
18
38
  * Throws when a name fails to resolve, when the chosen export is not a
19
39
  * function, or when the factory returns nothing.
20
40
  */
21
- export declare function resolveUserPlugins(plugins: ReadonlyArray<RolldownPluginOption | PluginByName>, projectDir: string): Promise<RolldownPluginOption[]>;
41
+ export declare function resolveUserPlugins(plugins: ReadonlyArray<RolldownPluginOption | PluginByName>, projectDir: string, options?: ResolveUserPluginsOptions): Promise<RolldownPluginOption[]>;
@@ -15,6 +15,8 @@
15
15
  import { createRequire } from 'node:module';
16
16
  import { join } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
+ /** Default loader: a plain dynamic import of the resolved file URL (Node path). */
19
+ const defaultLoadModule = async (resolvedPath) => (await import(pathToFileURL(resolvedPath).href));
18
20
  /** Type-guard: a `PluginByName` shape rather than a Rolldown plugin object. */
19
21
  export function isPluginByName(value) {
20
22
  return (typeof value === 'object' &&
@@ -39,7 +41,8 @@ export function isPluginByName(value) {
39
41
  * Throws when a name fails to resolve, when the chosen export is not a
40
42
  * function, or when the factory returns nothing.
41
43
  */
42
- export async function resolveUserPlugins(plugins, projectDir) {
44
+ export async function resolveUserPlugins(plugins, projectDir, options = {}) {
45
+ const loadModule = options.loadModule ?? defaultLoadModule;
43
46
  const requireFromProject = createRequire(join(projectDir, 'package.json'));
44
47
  const out = [];
45
48
  for (const entry of plugins) {
@@ -56,7 +59,7 @@ export async function resolveUserPlugins(plugins, projectDir) {
56
59
  `Add it to your project's dependencies, or pass a Plugin object directly. ` +
57
60
  `(${err.message})`);
58
61
  }
59
- const mod = await import(pathToFileURL(resolvedPath).href);
62
+ const mod = await loadModule(resolvedPath, entry.name);
60
63
  const exportName = entry.export ?? 'default';
61
64
  const factory = mod[exportName];
62
65
  if (typeof factory !== 'function') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.7.5",
3
+ "version": "0.9.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -120,19 +120,19 @@
120
120
  "cli"
121
121
  ],
122
122
  "dependencies": {
123
- "@gjsify/buffer": "^0.7.5",
124
- "@gjsify/create-app": "^0.7.5",
125
- "@gjsify/node-globals": "^0.7.5",
126
- "@gjsify/node-polyfills": "^0.7.5",
127
- "@gjsify/npm-registry": "^0.7.5",
128
- "@gjsify/resolve-npm": "^0.7.5",
129
- "@gjsify/rolldown-plugin-gjsify": "^0.7.5",
130
- "@gjsify/rolldown-plugin-pnp": "^0.7.5",
131
- "@gjsify/semver": "^0.7.5",
132
- "@gjsify/tar": "^0.7.5",
133
- "@gjsify/tsc": "^0.7.5",
134
- "@gjsify/web-polyfills": "^0.7.5",
135
- "@gjsify/workspace": "^0.7.5",
123
+ "@gjsify/buffer": "^0.9.0",
124
+ "@gjsify/create-app": "^0.9.0",
125
+ "@gjsify/node-globals": "^0.9.0",
126
+ "@gjsify/node-polyfills": "^0.9.0",
127
+ "@gjsify/npm-registry": "^0.9.0",
128
+ "@gjsify/resolve-npm": "^0.9.0",
129
+ "@gjsify/rolldown-plugin-gjsify": "^0.9.0",
130
+ "@gjsify/rolldown-plugin-pnp": "^0.9.0",
131
+ "@gjsify/semver": "^0.9.0",
132
+ "@gjsify/tar": "^0.9.0",
133
+ "@gjsify/tsc": "^0.9.0",
134
+ "@gjsify/web-polyfills": "^0.9.0",
135
+ "@gjsify/workspace": "^0.9.0",
136
136
  "cosmiconfig": "^9.0.2",
137
137
  "get-tsconfig": "^4.14.0",
138
138
  "pkg-types": "^2.3.1",
@@ -140,13 +140,13 @@
140
140
  "yargs": "^18.0.0"
141
141
  },
142
142
  "devDependencies": {
143
- "@gjsify/unit": "^0.7.5",
143
+ "@gjsify/unit": "^0.9.0",
144
144
  "@types/yargs": "^17.0.35",
145
145
  "typescript": "^6.0.3"
146
146
  },
147
147
  "peerDependencies": {
148
- "@gjsify/oxfmt-native": "^0.7.5",
149
- "@gjsify/rolldown-native": "^0.7.5"
148
+ "@gjsify/oxfmt-native": "^0.9.0",
149
+ "@gjsify/rolldown-native": "^0.9.0"
150
150
  },
151
151
  "peerDependenciesMeta": {
152
152
  "@gjsify/oxfmt-native": {