@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.
@@ -37,9 +37,40 @@ export declare class BuildAction {
37
37
  * there is no in-bundle plugin, so this hook is the sole injection point.
38
38
  */
39
39
  private applyShebang;
40
+ /**
41
+ * GJS plugin loader — bundle the resolved plugin module to a single
42
+ * self-contained ESM file, then import that. Works around GJS's native
43
+ * ESM loader not following `package.json#exports` subpath maps for bare
44
+ * specifiers: Rolldown resolves those subpaths at bundle time, so the
45
+ * emitted file has no unresolvable bare-specifier imports left. Injected
46
+ * as the `loadModule` strategy in `buildApp` only when running under GJS.
47
+ */
48
+ private loadPluginViaGjsBundle;
49
+ /**
50
+ * Bundle a single plugin entry for `--app gjs` to a cached temp file and
51
+ * return its path. Reuses the full `--app gjs` pipeline (exports-map-aware
52
+ * resolution, `node:`→`@gjsify` aliases, `--globals auto`, single-file
53
+ * output) via a nested `BuildAction`. The nested config carries no
54
+ * `bundler.plugins`, so it never recurses back into plugin resolution.
55
+ *
56
+ * Cached under `node_modules/.cache/gjsify/plugins/`, keyed by the resolved
57
+ * source path. Invalidated when the plugin entry OR the project lockfile is
58
+ * newer than the cached bundle — a dep bump rewrites the lockfile but not
59
+ * the plugin's own entry mtime. The one window neither mtime catches (a
60
+ * transitive dep edited in place) is covered by the `GJSIFY_NO_PLUGIN_CACHE=1`
61
+ * escape hatch. Failures are rethrown with context naming the plugin.
62
+ */
63
+ private bundlePluginForGjs;
64
+ /**
65
+ * Fresh when the cached bundle is newer than BOTH the plugin entry AND the
66
+ * project's lockfile (the dep-change signal). Missing bundle/source → not
67
+ * fresh (rebuild).
68
+ */
69
+ private isPluginBundleFresh;
40
70
  /** Application mode */
41
71
  buildApp(app?: App, opts?: {
42
72
  watch?: boolean;
73
+ preserveDefaultExport?: boolean;
43
74
  }): Promise<RolldownOutput[]>;
44
75
  /**
45
76
  * Drive `rolldown.watch(...)`: rebuild on source change, apply the
@@ -1,10 +1,12 @@
1
1
  import { runBundle, runWatch, bundleToChunks } from '../bundler-pick.js';
2
2
  import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG } from '@gjsify/rolldown-plugin-gjsify';
3
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
3
4
  import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
4
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals } from '@gjsify/rolldown-plugin-gjsify/globals';
5
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
6
- import { dirname, extname } from 'node:path';
7
- import { chmod, readFile, writeFile } from 'node:fs/promises';
7
+ import { dirname, extname, join } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
8
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
9
11
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
10
12
  /**
@@ -34,6 +36,43 @@ function isUnsafeDefaultOutput(path) {
34
36
  async function buildPnpPlugin() {
35
37
  return pnpPlugin({ issuerUrl: import.meta.url });
36
38
  }
39
+ /**
40
+ * Stable, filesystem-safe short hash of a string (djb2 → 8 hex chars).
41
+ * Used to key a plugin's cached GJS bundle by its resolved source path.
42
+ * Dependency-free on purpose — avoids pulling crypto into this hot path.
43
+ */
44
+ function shortHash(value) {
45
+ let h = 5381;
46
+ for (let i = 0; i < value.length; i++) {
47
+ h = ((h << 5) + h + value.charCodeAt(i)) >>> 0;
48
+ }
49
+ return h.toString(16).padStart(8, '0');
50
+ }
51
+ function isTruthyEnv(v) {
52
+ return v === '1' || v === 'true' || v === 'yes';
53
+ }
54
+ /**
55
+ * Lockfiles whose change implies a plugin's *transitive* deps may have moved.
56
+ * A dep bump (`npm/yarn/pnpm/gjsify install`) rewrites one of these but leaves
57
+ * the plugin's own entry-file mtime untouched — so the plugin GJS-bundle cache
58
+ * must invalidate on these too, not only on the entry.
59
+ */
60
+ const PLUGIN_CACHE_DEP_SIGNALS = ['gjsify-lock.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
61
+ /** Newest mtime (ms) among known lockfiles in `cwd`, or 0 when none exist. */
62
+ async function newestLockfileMtime(cwd) {
63
+ let newest = 0;
64
+ for (const name of PLUGIN_CACHE_DEP_SIGNALS) {
65
+ try {
66
+ const s = await stat(join(cwd, name));
67
+ if (s.mtimeMs > newest)
68
+ newest = s.mtimeMs;
69
+ }
70
+ catch {
71
+ // not present — ignore
72
+ }
73
+ }
74
+ return newest;
75
+ }
37
76
  export class BuildAction {
38
77
  configData;
39
78
  constructor(configData = {}) {
@@ -179,6 +218,90 @@ export class BuildAction {
179
218
  if (verbose)
180
219
  console.debug(`[gjsify] --shebang: wrote ${line} + chmod 0o755 to ${outfile}`);
181
220
  }
221
+ /**
222
+ * GJS plugin loader — bundle the resolved plugin module to a single
223
+ * self-contained ESM file, then import that. Works around GJS's native
224
+ * ESM loader not following `package.json#exports` subpath maps for bare
225
+ * specifiers: Rolldown resolves those subpaths at bundle time, so the
226
+ * emitted file has no unresolvable bare-specifier imports left. Injected
227
+ * as the `loadModule` strategy in `buildApp` only when running under GJS.
228
+ */
229
+ async loadPluginViaGjsBundle(resolvedPath, pluginName, verbose) {
230
+ const outfile = await this.bundlePluginForGjs(resolvedPath, pluginName, verbose);
231
+ try {
232
+ return (await import(pathToFileURL(outfile).href));
233
+ }
234
+ catch (err) {
235
+ throw new Error(`gjsify config: failed to import the GJS bundle for plugin "${pluginName}" ` +
236
+ `(bundled to ${outfile}). (${err.message})`);
237
+ }
238
+ }
239
+ /**
240
+ * Bundle a single plugin entry for `--app gjs` to a cached temp file and
241
+ * return its path. Reuses the full `--app gjs` pipeline (exports-map-aware
242
+ * resolution, `node:`→`@gjsify` aliases, `--globals auto`, single-file
243
+ * output) via a nested `BuildAction`. The nested config carries no
244
+ * `bundler.plugins`, so it never recurses back into plugin resolution.
245
+ *
246
+ * Cached under `node_modules/.cache/gjsify/plugins/`, keyed by the resolved
247
+ * source path. Invalidated when the plugin entry OR the project lockfile is
248
+ * newer than the cached bundle — a dep bump rewrites the lockfile but not
249
+ * the plugin's own entry mtime. The one window neither mtime catches (a
250
+ * transitive dep edited in place) is covered by the `GJSIFY_NO_PLUGIN_CACHE=1`
251
+ * escape hatch. Failures are rethrown with context naming the plugin.
252
+ */
253
+ async bundlePluginForGjs(resolvedPath, pluginName, verbose) {
254
+ const cwd = process.cwd();
255
+ const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify', 'plugins');
256
+ const safeName = pluginName.replace(/[^a-zA-Z0-9._-]/g, '_');
257
+ const outfile = join(cacheDir, `${safeName}-${shortHash(resolvedPath)}.mjs`);
258
+ const cacheDisabled = isTruthyEnv(process.env.GJSIFY_NO_PLUGIN_CACHE);
259
+ if (!cacheDisabled && (await this.isPluginBundleFresh(outfile, resolvedPath, cwd))) {
260
+ if (verbose)
261
+ console.debug(`[gjsify] plugin "${pluginName}": reusing cached GJS bundle ${outfile}`);
262
+ return outfile;
263
+ }
264
+ if (verbose)
265
+ console.debug(`[gjsify] plugin "${pluginName}": bundling for GJS → ${outfile}`);
266
+ try {
267
+ await mkdir(cacheDir, { recursive: true });
268
+ // `--globals auto` stays on so the plugin's own runtime globals
269
+ // (the MDX/unified toolchain touches `document`, etc.) are injected
270
+ // — but that injection wraps the entry with `export * from <entry>`,
271
+ // which drops the `default` export the plugin factory lives on.
272
+ // `preserveDefaultExport` makes the wrapper re-export `default` too,
273
+ // so the bundled plugin imports correctly as a library.
274
+ // shebang is intentionally left unset — the artifact is imported,
275
+ // not executed, so it must NOT carry a `#!` line.
276
+ const pluginBuild = new BuildAction({
277
+ verbose,
278
+ bundler: { input: resolvedPath, output: { file: outfile } },
279
+ });
280
+ await pluginBuild.buildApp('gjs', { preserveDefaultExport: true });
281
+ }
282
+ catch (err) {
283
+ throw new Error(`gjsify config: failed to bundle plugin "${pluginName}" for GJS ` +
284
+ `(needed because GJS can't import packages that use exports-map subpaths directly; ` +
285
+ `cache dir ${cacheDir}). (${err.message})`);
286
+ }
287
+ return outfile;
288
+ }
289
+ /**
290
+ * Fresh when the cached bundle is newer than BOTH the plugin entry AND the
291
+ * project's lockfile (the dep-change signal). Missing bundle/source → not
292
+ * fresh (rebuild).
293
+ */
294
+ async isPluginBundleFresh(outfile, resolvedPath, cwd) {
295
+ try {
296
+ const outStat = await stat(outfile);
297
+ const srcStat = await stat(resolvedPath);
298
+ const depMtime = await newestLockfileMtime(cwd);
299
+ return outStat.mtimeMs >= Math.max(srcStat.mtimeMs, depMtime);
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
182
305
  /** Application mode */
183
306
  async buildApp(app = 'gjs', opts = {}) {
184
307
  const { verbose, typescript, exclude, library: pkg, aliases, excludeGlobals } = this.configData;
@@ -211,6 +334,7 @@ export class BuildAction {
211
334
  reflection: typescript?.reflection,
212
335
  consoleShim,
213
336
  ...(aliases ? { aliases } : {}),
337
+ ...(opts.preserveDefaultExport ? { preserveDefaultExport: true } : {}),
214
338
  };
215
339
  const { autoMode, extras } = this.parseGlobalsValue(globals);
216
340
  const pnp = await buildPnpPlugin();
@@ -230,7 +354,15 @@ export class BuildAction {
230
354
  // auto-globals pre-build to avoid claiming the same files via
231
355
  // Rolldown's default classifier.
232
356
  if (userBundler.plugins?.length) {
233
- const resolved = await resolveUserPlugins(userBundler.plugins, process.cwd());
357
+ // Under GJS, plugin modules can't always be imported directly:
358
+ // GJS's native ESM loader doesn't follow `package.json#exports`
359
+ // subpath maps, so a plugin whose source imports an internal
360
+ // subpath (e.g. `@mdx-js/mdx/internal-…`) throws `Module not
361
+ // found` even when the file is on disk. The injected loader
362
+ // first bundles such a plugin to a single self-contained ESM
363
+ // (Rolldown resolves the exports map at bundle time) and imports
364
+ // that instead. On Node the default direct-import path is used.
365
+ const resolved = await resolveUserPlugins(userBundler.plugins, process.cwd(), isGjs() ? { loadModule: (p, name) => this.loadPluginViaGjsBundle(p, name, verbose) } : {});
234
366
  userPlugins.push(...resolved);
235
367
  }
236
368
  // --- Auto mode (with optional extras): iterative multi-pass build ---
@@ -32,6 +32,7 @@ import * as path from 'node:path';
32
32
  import { createRequire } from 'node:module';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { resolveNpmPackage } from './utils/resolve-npm-package.js';
35
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
35
36
  // npm `rolldown` is a Rust crate with platform-specific prebuilds; loading
36
37
  // it eagerly at module init pulls musl-detection code that does
37
38
  // `require('node:fs')` synchronously — fine on Node, but fatal under GJS
@@ -46,8 +47,7 @@ async function loadNpmRolldown() {
46
47
  // inside the crate's wrapper (e.g. on a truly-cold workspace where
47
48
  // `@gjsify/rolldown-native`'s lib/ is not built yet, issue #497).
48
49
  // Fail with the actionable diagnosis instead.
49
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
50
- if (isGjs) {
50
+ if (isGjs()) {
51
51
  throw new Error('gjsify build: no usable bundler engine under GJS — `@gjsify/rolldown-native` is not loadable ' +
52
52
  '(its JS facade lib/ is not built, or its prebuild typelib is missing from GI_TYPELIB_PATH), ' +
53
53
  'and the npm `rolldown` engine is a Rust napi crate that cannot run under GJS. ' +
@@ -83,8 +83,7 @@ async function loadNpmRolldown() {
83
83
  * rolldown` even though the package is present under the install dir.
84
84
  */
85
85
  function resolveImportTargetForGjs(specifier) {
86
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
87
- if (!isGjs)
86
+ if (!isGjs())
88
87
  return specifier;
89
88
  const resolved = resolveNpmPackage(specifier, { bundleUrl: import.meta.url });
90
89
  if (resolved)
@@ -193,8 +192,7 @@ export async function shouldUseNative() {
193
192
  // GJS: try @gjsify/rolldown-native — it's the only engine
194
193
  // that can actually run here (npm rolldown is a Rust
195
194
  // crate). Fall back to npm only on Node.
196
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
197
- if (!isGjs)
195
+ if (!isGjs())
198
196
  return false;
199
197
  const native = await tryLoadNative();
200
198
  return native !== null;
@@ -236,8 +234,7 @@ async function tryLoadNative() {
236
234
  if (_nativeProbe)
237
235
  return _nativeProbe;
238
236
  _nativeProbe = (async () => {
239
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
240
- if (!isGjs)
237
+ if (!isGjs())
241
238
  return null;
242
239
  try {
243
240
  // Under GJS the ESM loader has no node_modules resolver — a bare
@@ -256,7 +253,7 @@ async function tryLoadNative() {
256
253
  // to npm rolldown (which then throws ImportError for `rolldown`).
257
254
  const specifier = '@gjsify/rolldown-native';
258
255
  let target = specifier;
259
- if (isGjs) {
256
+ if (isGjs()) {
260
257
  // Same multi-anchor resolution as `loadNpmRolldown` —
261
258
  // when the bundle is invoked from a cwd outside the
262
259
  // install dir, anchoring solely at `import.meta.url`
package/lib/cli-app.js CHANGED
@@ -19,8 +19,9 @@ 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, } 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, 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, } from './commands/index.js';
23
23
  import { APP_NAME } from './constants.js';
24
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
24
25
  // Detect which runtime is executing the CLI (GJS or Node.js).
25
26
  // GJS MUST be checked first because @gjsify/process sets
26
27
  // `process.versions.node = '20.0.0'` for compatibility — a plain
@@ -36,7 +37,7 @@ function runtimeLabel() {
36
37
  catch {
37
38
  /* not GJS */
38
39
  }
39
- if (typeof process !== 'undefined' && typeof process.versions?.node === 'string') {
40
+ if (isNode()) {
40
41
  return `Node.js ${process.version}`;
41
42
  }
42
43
  return 'unknown runtime';
@@ -106,6 +107,7 @@ export async function runCli(argv) {
106
107
  .command(barrels.command, barrels.description, barrels.builder, barrels.handler)
107
108
  .command(tsc.command, tsc.description, tsc.builder, tsc.handler)
108
109
  .command(affected.command, affected.description, affected.builder, affected.handler)
110
+ .command(storybook.command, storybook.description, storybook.builder, storybook.handler)
109
111
  .demandCommand(1)
110
112
  .epilogue(`Running on ${runtimeLabel()}`)
111
113
  .help()
@@ -30,3 +30,4 @@ export * from './upgrade.js';
30
30
  export * from './barrels.js';
31
31
  export * from './tsc.js';
32
32
  export * from './affected.js';
33
+ export * from './storybook.js';
@@ -30,3 +30,4 @@ export * from './upgrade.js';
30
30
  export * from './barrels.js';
31
31
  export * from './tsc.js';
32
32
  export * from './affected.js';
33
+ export * from './storybook.js';
@@ -21,7 +21,7 @@
21
21
  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
- import { promptLine, promptHidden } from '../utils/prompt.js';
24
+ import { promptCredentials, promptLine } from '../utils/prompt.js';
25
25
  export const loginCommand = {
26
26
  command: 'login',
27
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.",
@@ -44,12 +44,15 @@ export const loginCommand = {
44
44
  (args.scope ? registryFor(args.scope.startsWith('@') ? `${args.scope}/_` : `@${args.scope}/_`, npmrc) : undefined) ??
45
45
  DEFAULT_REGISTRY;
46
46
  const registryClean = registry.endsWith('/') ? registry : `${registry}/`;
47
- const username = args.username ?? (await promptLine(`Username: `));
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);
48
52
  if (!username) {
49
53
  console.error('gjsify login: a username is required.');
50
54
  process.exit(1);
51
55
  }
52
- const password = await promptHidden(`Password: `);
53
56
  if (!password) {
54
57
  console.error('gjsify login: a password is required.');
55
58
  process.exit(1);
@@ -19,6 +19,7 @@ import { spawn } from 'node:child_process';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
20
  import { readPackageJson } from '../utils/pkg-json-edit.js';
21
21
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
22
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
23
  export const runCommand = {
23
24
  command: 'run <target> [args..]',
24
25
  description: 'Run a script from package.json (yarn-run-style) or a GJS bundle file. If <target> resolves to a file on disk (or has a path-like prefix), it is launched via gjs with LD_LIBRARY_PATH + GI_TYPELIB_PATH set for native packages. Otherwise it is looked up in the current package.json `scripts`.',
@@ -151,7 +152,7 @@ async function runScript(script, extraArgs) {
151
152
  // Anything fancier (compound `&&`, pipes, a non-gjsify command) falls
152
153
  // through to the shell spawn below. Node keeps spawning (cheap there); this
153
154
  // only triggers under GJS, where the nesting actually hurts. See cli-app.ts.
154
- const inProcArgv = runningUnderGjs() ? gjsifyInProcessArgv(literal, extraArgs) : null;
155
+ const inProcArgv = isGjs() ? gjsifyInProcessArgv(literal, extraArgs) : null;
155
156
  if (inProcArgv) {
156
157
  // The subcommand runs in our process, so surface the script env on
157
158
  // `process.env` (PATH with the workspace .bin dirs, npm_* lifecycle
@@ -203,11 +204,6 @@ function shellEscape(arg) {
203
204
  return arg;
204
205
  return `'${arg.replace(/'/g, "'\\''")}'`;
205
206
  }
206
- // Detected at CALL time (a module-load const reads `undefined` in the bundled
207
- // `--app gjs` form). `bundler-pick.ts` uses the same expression.
208
- function runningUnderGjs() {
209
- return typeof globalThis.imports?.gi !== 'undefined';
210
- }
211
207
  /**
212
208
  * If `literal` is a SINGLE `gjsify <subcommand> …` command with no shell
213
209
  * operators / substitutions / unquoted globs, return the argv to feed `runCli`
@@ -0,0 +1,12 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface StorybookCliOptions {
3
+ stories?: string;
4
+ appId?: string;
5
+ title?: string;
6
+ globals?: string;
7
+ out?: string;
8
+ watch: boolean;
9
+ buildOnly: boolean;
10
+ }
11
+ export declare const storybookCommand: Command<unknown, StorybookCliOptions>;
12
+ export {};
@@ -0,0 +1,172 @@
1
+ // `gjsify storybook` — discover every *.story.ts in a project, generate a tiny
2
+ // entry that registers them, then build (`--app gjs`) and launch the generic
3
+ // @gjsify/storybook component browser. No per-project storybook app needed.
4
+ //
5
+ // Dispatch is IN-PROCESS (runCli + runGjsBundle), not a re-spawn of the CLI:
6
+ // the showcase-style `spawn(process.execPath, [cliBin, …])` pattern assumes
7
+ // Node, but under the committed GJS bundle `process.execPath` is `gjs` and the
8
+ // `../index.js` path is not a runnable entry. In-process dispatch works
9
+ // identically under Node and GJS. @gjsify/storybook is resolved from the
10
+ // CONSUMER's node_modules by the generated entry, so it stays out of the CLI's
11
+ // own dependencies.
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join, relative, resolve } from 'node:path';
15
+ function readPackage(cwd) {
16
+ try {
17
+ return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ /** Derive a stable GApplication id from the package name when none is configured. */
24
+ function deriveAppId(name) {
25
+ if (!name)
26
+ return 'org.gjsify.Storybook';
27
+ const slug = name
28
+ .replace(/^@/, '')
29
+ .replace(/[^a-zA-Z0-9]+/g, '.')
30
+ .replace(/^\.+|\.+$/g, '');
31
+ return `org.gjsify.storybook.${slug}`;
32
+ }
33
+ /** Recursively collect `*.story.{ts,js,mts,mjs}` files under a directory. */
34
+ function findStoryFiles(dir) {
35
+ const out = [];
36
+ if (!existsSync(dir))
37
+ return out;
38
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
39
+ if (entry.name === 'node_modules' || entry.name.startsWith('.'))
40
+ continue;
41
+ const full = join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ out.push(...findStoryFiles(full));
44
+ }
45
+ else if (/\.story\.(ts|js|mts|mjs)$/.test(entry.name)) {
46
+ out.push(full);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ function generateEntry(files, applicationId, title) {
52
+ const imports = files.map((file, i) => `import * as m${i} from ${JSON.stringify(file)};`).join('\n');
53
+ const namespaces = files.map((_, i) => `m${i}`).join(', ');
54
+ const titleLine = title ? `\n title: ${JSON.stringify(title)},` : '';
55
+ return `// Auto-generated by \`gjsify storybook\` — do not edit by hand.
56
+ import { collectStoryModules, runStorybook } from '@gjsify/storybook';
57
+ ${imports}
58
+
59
+ await runStorybook({
60
+ applicationId: ${JSON.stringify(applicationId)},${titleLine}
61
+ stories: collectStoryModules([${namespaces}]),
62
+ });
63
+ `;
64
+ }
65
+ export const storybookCommand = {
66
+ command: 'storybook',
67
+ description: 'Discover *.story.ts in this project and launch the GTK storybook browser.',
68
+ builder: (yargs) => yargs
69
+ .option('stories', {
70
+ description: 'Directory scanned recursively for *.story.ts (default: src, or gjsify.storybook.stories)',
71
+ type: 'string',
72
+ })
73
+ .option('app-id', {
74
+ description: 'GApplication id (default: gjsify.storybook.applicationId or derived from the package name)',
75
+ type: 'string',
76
+ })
77
+ .option('title', { description: 'Window title', type: 'string' })
78
+ .option('globals', {
79
+ description: 'Value for `gjsify build --globals` (default: auto; use auto,dom for canvas/DOM stories)',
80
+ type: 'string',
81
+ })
82
+ .option('out', {
83
+ description: 'Output bundle path (default: node_modules/.cache/gjsify-storybook)',
84
+ type: 'string',
85
+ })
86
+ .option('watch', {
87
+ description: 'Rebuild and relaunch when a story file changes',
88
+ type: 'boolean',
89
+ default: false,
90
+ })
91
+ .option('build-only', { description: 'Build the bundle but do not launch it', type: 'boolean', default: false }),
92
+ handler: async (args) => {
93
+ const cwd = process.cwd();
94
+ const pkg = readPackage(cwd);
95
+ const config = pkg.gjsify?.storybook ?? {};
96
+ const storiesDir = resolve(cwd, args.stories ?? config.stories ?? 'src');
97
+ const applicationId = args.appId ?? config.applicationId ?? deriveAppId(pkg.name);
98
+ const title = args.title ?? config.title;
99
+ const globals = args.globals ?? config.globals ?? 'auto';
100
+ const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-storybook');
101
+ mkdirSync(cacheDir, { recursive: true });
102
+ const entryPath = join(cacheDir, 'entry.ts');
103
+ const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'storybook.gjs.mjs');
104
+ const writeEntry = () => {
105
+ const files = findStoryFiles(storiesDir).sort();
106
+ writeFileSync(entryPath, generateEntry(files, applicationId, title));
107
+ return files.length;
108
+ };
109
+ const initialCount = writeEntry();
110
+ if (initialCount === 0) {
111
+ console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}`);
112
+ process.exit(1);
113
+ }
114
+ console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'}`);
115
+ // In-process build — runtime-agnostic (Node uses npm rolldown, GJS uses
116
+ // the @gjsify/rolldown-native facade), no re-spawn of the CLI binary.
117
+ const { runCli } = await import('../cli-app.js');
118
+ const buildBundle = () => runCli(['build', entryPath, '--app', 'gjs', '--globals', globals, '--outfile', outPath]);
119
+ if (!args.watch) {
120
+ await buildBundle();
121
+ if (args.buildOnly)
122
+ return;
123
+ const { runGjsBundle } = await import('../utils/run-gjs.js');
124
+ await runGjsBundle(outPath, [], { exitOnSuccess: true });
125
+ return;
126
+ }
127
+ // Watch mode: rebuild + relaunch on any change under the stories dir.
128
+ // The storybook bundle only needs system Gtk/Adw typelibs at runtime
129
+ // (no @gjsify native bridges), so a plain `gjs -m` child is enough and
130
+ // stays killable for relaunch.
131
+ const { watch } = await import('node:fs');
132
+ let child = null;
133
+ let rebuilding = false;
134
+ let pending = false;
135
+ const rebuild = async () => {
136
+ if (rebuilding) {
137
+ pending = true;
138
+ return;
139
+ }
140
+ rebuilding = true;
141
+ try {
142
+ writeEntry();
143
+ if (child) {
144
+ child.kill();
145
+ child = null;
146
+ }
147
+ await buildBundle();
148
+ if (!args.buildOnly) {
149
+ child = spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
150
+ }
151
+ }
152
+ catch (err) {
153
+ console.error(`[storybook] rebuild failed: ${err.message}`);
154
+ }
155
+ finally {
156
+ rebuilding = false;
157
+ if (pending) {
158
+ pending = false;
159
+ void rebuild();
160
+ }
161
+ }
162
+ };
163
+ await rebuild();
164
+ let debounce = null;
165
+ watch(storiesDir, { recursive: true }, () => {
166
+ if (debounce)
167
+ clearTimeout(debounce);
168
+ debounce = setTimeout(() => void rebuild(), 200);
169
+ });
170
+ console.log(`[storybook] watching ${relative(cwd, storiesDir) || '.'} — press Ctrl+C to stop`);
171
+ },
172
+ };
@@ -53,6 +53,7 @@ import { createRequire } from 'node:module';
53
53
  import { pathToFileURL } from 'node:url';
54
54
  import { findWorkspaceRoot } from './workspace-root.js';
55
55
  import { resolveNpmPackage } from './resolve-npm-package.js';
56
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
56
57
  /**
57
58
  * Map Node.js `process.platform` + `arch` (+ musl on Linux) to the
58
59
  * napi-rs binding-package suffix oxc uses, e.g. `linux-x64-gnu`,
@@ -210,8 +211,7 @@ async function tryLoadNativeOxfmt() {
210
211
  if (_nativeOxfmtProbe)
211
212
  return _nativeOxfmtProbe;
212
213
  _nativeOxfmtProbe = (async () => {
213
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
214
- if (!isGjs)
214
+ if (!isGjs())
215
215
  return null;
216
216
  try {
217
217
  const specifier = '@gjsify/oxfmt-native';
@@ -249,8 +249,7 @@ export async function shouldUseNativeOxfmt() {
249
249
  }
250
250
  return true;
251
251
  }
252
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
253
- if (!isGjs)
252
+ if (!isGjs())
254
253
  return false;
255
254
  return (await tryLoadNativeOxfmt()) !== null;
256
255
  }
@@ -1,8 +1,34 @@
1
- /** Print a question and read one line from stdin (visible). */
2
- export declare function promptLine(question: string): Promise<string>;
1
+ /** Credentials read from the terminal. */
2
+ export interface PromptedCredentials {
3
+ username: string;
4
+ password: string;
5
+ }
6
+ /** Result of feeding one keystroke to the raw-mode line editor. */
7
+ export interface KeyOutcome {
8
+ /** The line buffer after this key. */
9
+ buf: string;
10
+ /** Text to echo to the terminal for this key (`''` = nothing). */
11
+ echo: string;
12
+ /** The line is complete (Enter). */
13
+ done: boolean;
14
+ /** Ctrl-C was pressed (caller should restore + exit). */
15
+ interrupt: boolean;
16
+ }
3
17
  /**
4
- * Print a question and read one line WITHOUT echoing it (passwords). Uses raw
5
- * mode + manual key handling on a TTY; falls back to a plain line read when
6
- * stdin is not a TTY (piped). Ctrl-C aborts the process.
18
+ * Pure key handler for the raw-mode line editor — the single source of truth
19
+ * for how a keystroke updates the buffer + echo. Kept pure (no I/O) so it is
20
+ * unit-testable: this is what makes Enter (`\r` OR `\n`) reliably submit, masks
21
+ * passwords, and treats Ctrl-C as an interrupt rather than text.
7
22
  */
23
+ export declare function applyKey(buf: string, ch: string, mask: boolean): KeyOutcome;
24
+ /** Print a question and read one visible line. */
25
+ export declare function promptLine(question: string): Promise<string>;
26
+ /** Print a question and read one line WITHOUT echoing it (passwords). */
8
27
  export declare function promptHidden(question: string): Promise<string>;
28
+ /**
29
+ * Read npm credentials. On a TTY both the visible username and the masked
30
+ * password are read in ONE raw session (no cooked-line dependency, no
31
+ * inter-prompt resume/pause race). `providedUsername` (from `--username`) skips
32
+ * the username prompt.
33
+ */
34
+ export declare function promptCredentials(providedUsername?: string): Promise<PromptedCredentials>;