@gjsify/cli 0.1.10 → 0.1.12

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.
@@ -26,6 +26,11 @@ export declare class BuildAction {
26
26
  * The auto path is handled in `buildApp` via the two-pass build.
27
27
  */
28
28
  private resolveGlobalsInject;
29
+ /**
30
+ * Post-processing: prepend GJS shebang and mark the output file executable.
31
+ * Only runs for GJS app builds with a resolvable single outfile.
32
+ */
33
+ private applyShebang;
29
34
  /** Application mode */
30
35
  buildApp(app?: App): Promise<BuildResult<{
31
36
  format: "esm" | "cjs";
@@ -1,7 +1,9 @@
1
1
  import { build } from 'esbuild';
2
2
  import { gjsifyPlugin } from '@gjsify/esbuild-plugin-gjsify';
3
3
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals } from '@gjsify/esbuild-plugin-gjsify/globals';
4
- import { dirname, extname } from 'path';
4
+ import { dirname, extname } from 'node:path';
5
+ import { chmod, readFile, writeFile } from 'node:fs/promises';
6
+ const GJS_SHEBANG = '#!/usr/bin/env -S gjs -m\n';
5
7
  export class BuildAction {
6
8
  configData;
7
9
  constructor(configData = {}) {
@@ -105,6 +107,28 @@ export class BuildAction {
105
107
  }
106
108
  return injectPath ?? undefined;
107
109
  }
110
+ /**
111
+ * Post-processing: prepend GJS shebang and mark the output file executable.
112
+ * Only runs for GJS app builds with a resolvable single outfile.
113
+ */
114
+ async applyShebang(outfile, verbose) {
115
+ if (!outfile) {
116
+ if (verbose)
117
+ console.warn('[gjsify] --shebang skipped: no single outfile (use --outfile for GJS executables)');
118
+ return;
119
+ }
120
+ const content = await readFile(outfile, 'utf-8');
121
+ if (content.startsWith('#!')) {
122
+ if (verbose)
123
+ console.debug(`[gjsify] --shebang skipped: ${outfile} already starts with a shebang`);
124
+ }
125
+ else {
126
+ await writeFile(outfile, GJS_SHEBANG + content);
127
+ }
128
+ await chmod(outfile, 0o755);
129
+ if (verbose)
130
+ console.debug(`[gjsify] --shebang: wrote shebang + chmod 0o755 to ${outfile}`);
131
+ }
108
132
  /** Application mode */
109
133
  async buildApp(app = 'gjs') {
110
134
  const { verbose, esbuild, typescript, exclude, library: pgk } = this.configData;
@@ -140,6 +164,9 @@ export class BuildAction {
140
164
  }),
141
165
  ],
142
166
  });
167
+ if (app === 'gjs' && this.configData.shebang) {
168
+ await this.applyShebang(esbuild?.outfile, verbose);
169
+ }
143
170
  return [result];
144
171
  }
145
172
  // --- Explicit list (no `auto` token) or none mode ---
@@ -157,6 +184,9 @@ export class BuildAction {
157
184
  }),
158
185
  ]
159
186
  });
187
+ if (app === 'gjs' && this.configData.shebang) {
188
+ await this.applyShebang(esbuild?.outfile, verbose);
189
+ }
160
190
  return [result];
161
191
  }
162
192
  async start(buildType = { app: 'gjs' }) {
@@ -91,6 +91,12 @@ export const buildCommand = {
91
91
  type: 'string',
92
92
  normalize: true,
93
93
  default: 'auto'
94
+ })
95
+ .option('shebang', {
96
+ description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile.",
97
+ type: 'boolean',
98
+ normalize: true,
99
+ default: false
94
100
  });
95
101
  },
96
102
  handler: async (args) => {
@@ -1,6 +1,9 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  interface CreateOptions {
3
3
  'project-name': string;
4
+ template?: string;
5
+ force: boolean;
6
+ install: boolean;
4
7
  }
5
8
  export declare const createCommand: Command<any, CreateOptions>;
6
9
  export {};
@@ -1,15 +1,52 @@
1
- import { createProject } from '@gjsify/create-app';
1
+ import { createProject, discoverTemplates } from '@gjsify/create-app';
2
+ import { promptTemplate } from '@gjsify/create-app/prompt';
2
3
  export const createCommand = {
3
4
  command: 'create [project-name]',
4
5
  description: 'Scaffold a new Gjsify project in a new directory.',
5
6
  builder: (yargs) => {
6
- return yargs.positional('project-name', {
7
+ const templates = discoverTemplates();
8
+ const templateChoices = templates.map((t) => t.name);
9
+ return yargs
10
+ .positional('project-name', {
7
11
  describe: 'Name of the project directory to create',
8
12
  type: 'string',
9
13
  default: 'my-gjs-app',
14
+ })
15
+ .option('template', {
16
+ alias: 't',
17
+ describe: 'Template to scaffold from',
18
+ type: 'string',
19
+ choices: templateChoices.length > 0 ? templateChoices : undefined,
20
+ })
21
+ .option('force', {
22
+ alias: 'f',
23
+ describe: 'Scaffold into a non-empty directory',
24
+ type: 'boolean',
25
+ default: false,
26
+ })
27
+ .option('install', {
28
+ describe: 'Run npm install after scaffolding',
29
+ type: 'boolean',
30
+ default: false,
10
31
  });
11
32
  },
12
33
  handler: async (args) => {
13
- await createProject(args['project-name']);
34
+ let template = args.template;
35
+ if (!template) {
36
+ const templates = discoverTemplates();
37
+ if (!process.stdin.isTTY) {
38
+ const list = templates.map((t) => t.name).join(', ');
39
+ console.error(`Error: --template is required in non-interactive mode. Available templates: ${list || '(none)'}`);
40
+ process.exit(1);
41
+ }
42
+ const picked = await promptTemplate(templates);
43
+ template = picked.name;
44
+ }
45
+ await createProject({
46
+ projectName: args['project-name'],
47
+ template,
48
+ force: args.force,
49
+ install: args.install,
50
+ });
14
51
  },
15
52
  };
@@ -0,0 +1,14 @@
1
+ import type { Command } from '../types/index.js';
2
+ type GettextFormat = 'mo' | 'xml' | 'desktop' | 'json';
3
+ interface GettextOptions {
4
+ poDir: string;
5
+ outDir: string;
6
+ domain: string;
7
+ format?: GettextFormat;
8
+ metainfo?: string;
9
+ filename?: string;
10
+ removeXmlComments?: boolean;
11
+ verbose?: boolean;
12
+ }
13
+ export declare const gettextCommand: Command<any, GettextOptions>;
14
+ export {};
@@ -0,0 +1,201 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFile);
6
+ async function listLanguages(poDir) {
7
+ const entries = await readdir(poDir);
8
+ return entries
9
+ .filter((name) => name.endsWith('.po') && !name.startsWith('.'))
10
+ .map((name) => name.slice(0, -3))
11
+ .sort();
12
+ }
13
+ function stripXmlComments(source) {
14
+ return source.replace(/<!--[\s\S]*?-->/g, '');
15
+ }
16
+ async function ensureDir(path) {
17
+ await mkdir(path, { recursive: true });
18
+ }
19
+ async function fileExists(path) {
20
+ try {
21
+ await stat(path);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ async function compileBulkXml(opts) {
29
+ const outputFile = join(opts.outDir, opts.filename);
30
+ await ensureDir(opts.outDir);
31
+ const args = [
32
+ `--output-file=${outputFile}`,
33
+ '--xml',
34
+ `--template=${opts.template}`,
35
+ '-d',
36
+ opts.poDir,
37
+ ];
38
+ if (opts.verbose) {
39
+ console.log(`[gjsify gettext] msgfmt ${args.join(' ')}`);
40
+ }
41
+ await execFileAsync('msgfmt', args);
42
+ if (opts.removeXmlComments) {
43
+ const content = await readFile(outputFile, 'utf-8');
44
+ await writeFile(outputFile, stripXmlComments(content));
45
+ }
46
+ if (opts.verbose) {
47
+ console.log(`[gjsify gettext] wrote ${outputFile}`);
48
+ }
49
+ }
50
+ async function compilePerLanguage(opts) {
51
+ const languages = await listLanguages(opts.poDir);
52
+ if (languages.length === 0) {
53
+ console.warn(`[gjsify gettext] no .po files found in ${opts.poDir}`);
54
+ return;
55
+ }
56
+ for (const lang of languages) {
57
+ const poFile = join(opts.poDir, `${lang}.po`);
58
+ const langDir = opts.format === 'mo'
59
+ ? join(opts.outDir, lang, 'LC_MESSAGES')
60
+ : join(opts.outDir, lang);
61
+ await ensureDir(langDir);
62
+ const outputFile = join(langDir, opts.filename);
63
+ // msgfmt produces the binary .mo format by default — there is no
64
+ // `--mo` flag (only --xml, --desktop, --properties-output, ...).
65
+ const args = [`--output-file=${outputFile}`];
66
+ if (opts.format !== 'mo') {
67
+ args.push(`--${opts.format}`);
68
+ }
69
+ args.push(poFile);
70
+ if (opts.verbose) {
71
+ console.log(`[gjsify gettext] msgfmt ${args.join(' ')}`);
72
+ }
73
+ await execFileAsync('msgfmt', args);
74
+ }
75
+ if (opts.verbose) {
76
+ console.log(`[gjsify gettext] compiled ${languages.length} language(s) into ${opts.outDir}`);
77
+ }
78
+ }
79
+ function defaultFilename(domain, format, metainfoTemplate) {
80
+ switch (format) {
81
+ case 'mo':
82
+ return `${domain}.mo`;
83
+ case 'json':
84
+ return `${domain}.json`;
85
+ case 'desktop':
86
+ return `${domain}.desktop`;
87
+ case 'xml': {
88
+ // Mirror the template filename but without the trailing `.in` (convention for
89
+ // pre-processed metainfo templates: `org.foo.Bar.metainfo.xml.in`).
90
+ if (metainfoTemplate) {
91
+ const base = metainfoTemplate.replace(/\.in$/, '');
92
+ const slash = base.lastIndexOf('/');
93
+ return slash >= 0 ? base.slice(slash + 1) : base;
94
+ }
95
+ return `${domain}.xml`;
96
+ }
97
+ }
98
+ }
99
+ export const gettextCommand = {
100
+ command: 'gettext <poDir> <outDir>',
101
+ description: 'Compile gettext .po files to .mo (per-language locale tree) or substitute a metainfo template via msgfmt --xml.',
102
+ builder: (yargs) => {
103
+ return yargs
104
+ .positional('poDir', {
105
+ description: 'Directory containing <lang>.po files',
106
+ type: 'string',
107
+ normalize: true,
108
+ demandOption: true,
109
+ })
110
+ .positional('outDir', {
111
+ description: 'Output directory (locale tree for --format=mo, plain dir for xml/desktop/json)',
112
+ type: 'string',
113
+ normalize: true,
114
+ demandOption: true,
115
+ })
116
+ .option('domain', {
117
+ description: 'Text domain / application ID (e.g. `org.pixelrpg.maker`)',
118
+ type: 'string',
119
+ normalize: true,
120
+ demandOption: true,
121
+ })
122
+ .option('format', {
123
+ description: 'Output format',
124
+ type: 'string',
125
+ choices: ['mo', 'xml', 'desktop', 'json'],
126
+ default: 'mo',
127
+ })
128
+ .option('metainfo', {
129
+ description: 'For --format=xml: path to the template (`.metainfo.xml.in`) used as msgfmt --template',
130
+ type: 'string',
131
+ normalize: true,
132
+ })
133
+ .option('filename', {
134
+ description: 'Override the output filename (defaults to <domain>.<ext>)',
135
+ type: 'string',
136
+ normalize: true,
137
+ })
138
+ .option('remove-xml-comments', {
139
+ description: 'For --format=xml: strip XML comments from the compiled output',
140
+ type: 'boolean',
141
+ default: true,
142
+ })
143
+ .option('verbose', {
144
+ description: 'Print each msgfmt invocation',
145
+ type: 'boolean',
146
+ default: false,
147
+ });
148
+ },
149
+ handler: async (args) => {
150
+ const poDir = resolve(args.poDir);
151
+ const outDir = resolve(args.outDir);
152
+ const domain = args.domain;
153
+ const format = args.format ?? 'mo';
154
+ const metainfo = args.metainfo ? resolve(args.metainfo) : undefined;
155
+ const filename = args.filename ?? defaultFilename(domain, format, metainfo);
156
+ const verbose = !!args.verbose;
157
+ const removeXmlComments = !!args['remove-xml-comments'];
158
+ if (!(await fileExists(poDir))) {
159
+ console.error(`[gjsify gettext] PO directory does not exist: ${poDir}`);
160
+ process.exitCode = 1;
161
+ return;
162
+ }
163
+ try {
164
+ if (format === 'xml' && metainfo) {
165
+ await compileBulkXml({
166
+ poDir,
167
+ outDir,
168
+ domain,
169
+ template: metainfo,
170
+ filename,
171
+ removeXmlComments,
172
+ verbose,
173
+ });
174
+ }
175
+ else {
176
+ if (format === 'xml') {
177
+ console.warn('[gjsify gettext] --format=xml without --metainfo: falling back to per-language XML files');
178
+ }
179
+ await compilePerLanguage({
180
+ poDir,
181
+ outDir,
182
+ domain,
183
+ format,
184
+ filename,
185
+ verbose,
186
+ });
187
+ }
188
+ }
189
+ catch (err) {
190
+ if (err?.code === 'ENOENT') {
191
+ console.error('[gjsify gettext] msgfmt not found. Install it via your distro (package: gettext).');
192
+ }
193
+ else {
194
+ if (err?.stderr)
195
+ process.stderr.write(err.stderr);
196
+ console.error(`[gjsify gettext] msgfmt failed${err?.code !== undefined ? ` (exit ${err.code})` : ''}`);
197
+ }
198
+ process.exitCode = typeof err?.code === 'number' ? err.code : 1;
199
+ }
200
+ },
201
+ };
@@ -0,0 +1,9 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface GResourceOptions {
3
+ xml: string;
4
+ sourcedir?: string;
5
+ target?: string;
6
+ verbose?: boolean;
7
+ }
8
+ export declare const gresourceCommand: Command<any, GResourceOptions>;
9
+ export {};
@@ -0,0 +1,82 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import { basename, dirname, extname, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFile);
6
+ /**
7
+ * Derive a default `.gresource` target path from the XML descriptor.
8
+ * `foo.gresource.xml` → `foo.gresource` in the same directory.
9
+ */
10
+ function defaultTargetFor(xmlPath) {
11
+ const ext = extname(xmlPath);
12
+ const stem = basename(xmlPath, ext);
13
+ return resolve(dirname(xmlPath), stem);
14
+ }
15
+ export const gresourceCommand = {
16
+ command: 'gresource <xml>',
17
+ description: 'Compile a GResource XML descriptor into a binary .gresource bundle (wraps `glib-compile-resources`).',
18
+ builder: (yargs) => {
19
+ return yargs
20
+ .positional('xml', {
21
+ description: 'Path to the .gresource.xml descriptor',
22
+ type: 'string',
23
+ normalize: true,
24
+ demandOption: true,
25
+ })
26
+ .option('sourcedir', {
27
+ description: 'Directory containing the resource files referenced by <xml>',
28
+ type: 'string',
29
+ normalize: true,
30
+ })
31
+ .option('target', {
32
+ alias: 't',
33
+ description: 'Output .gresource file (default: <xml-without-.xml> next to <xml>)',
34
+ type: 'string',
35
+ normalize: true,
36
+ })
37
+ .option('verbose', {
38
+ description: 'Print the underlying glib-compile-resources invocation',
39
+ type: 'boolean',
40
+ default: false,
41
+ });
42
+ },
43
+ handler: async (args) => {
44
+ const xmlPath = resolve(args.xml);
45
+ const target = args.target ? resolve(args.target) : defaultTargetFor(xmlPath);
46
+ const sourcedir = args.sourcedir
47
+ ? resolve(args.sourcedir)
48
+ : dirname(xmlPath);
49
+ const cmdArgs = [
50
+ `--sourcedir=${sourcedir}`,
51
+ `--target=${target}`,
52
+ xmlPath,
53
+ ];
54
+ if (args.verbose) {
55
+ console.log(`[gjsify gresource] glib-compile-resources ${cmdArgs.join(' ')}`);
56
+ }
57
+ // Ensure the target directory exists — glib-compile-resources writes
58
+ // a temporary file next to the target and fails with ENOENT otherwise.
59
+ await mkdir(dirname(target), { recursive: true });
60
+ try {
61
+ const { stdout, stderr } = await execFileAsync('glib-compile-resources', cmdArgs);
62
+ if (stdout)
63
+ process.stdout.write(stdout);
64
+ if (stderr)
65
+ process.stderr.write(stderr);
66
+ if (args.verbose) {
67
+ console.log(`[gjsify gresource] wrote ${target}`);
68
+ }
69
+ }
70
+ catch (err) {
71
+ if (err?.code === 'ENOENT') {
72
+ console.error('[gjsify gresource] glib-compile-resources not found. Install it via your distro (package: glib2-devel / libglib2.0-dev).');
73
+ }
74
+ else {
75
+ if (err?.stderr)
76
+ process.stderr.write(err.stderr);
77
+ console.error(`[gjsify gresource] glib-compile-resources failed${err?.code !== undefined ? ` (exit ${err.code})` : ''}`);
78
+ }
79
+ process.exitCode = typeof err?.code === 'number' ? err.code : 1;
80
+ }
81
+ },
82
+ };
@@ -4,3 +4,5 @@ export * from './info.js';
4
4
  export * from './check.js';
5
5
  export * from './showcase.js';
6
6
  export * from './create.js';
7
+ export * from './gresource.js';
8
+ export * from './gettext.js';
@@ -4,3 +4,5 @@ export * from './info.js';
4
4
  export * from './check.js';
5
5
  export * from './showcase.js';
6
6
  export * from './create.js';
7
+ export * from './gresource.js';
8
+ export * from './gettext.js';
package/lib/config.js CHANGED
@@ -71,6 +71,8 @@ export class Config {
71
71
  configData.consoleShim = cliArgs.consoleShim;
72
72
  if (cliArgs.globals !== undefined)
73
73
  configData.globals = cliArgs.globals;
74
+ if (cliArgs.shebang !== undefined)
75
+ configData.shebang = cliArgs.shebang;
74
76
  merge(configData.library ??= {}, pkg, configData.library);
75
77
  merge(configData.typescript ??= {}, tsConfig, configData.typescript);
76
78
  merge(configData.esbuild ??= {}, {
package/lib/index.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import yargs from 'yargs';
3
3
  import { hideBin } from 'yargs/helpers';
4
- import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create } from './commands/index.js';
4
+ import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, } from './commands/index.js';
5
5
  import { APP_NAME } from './constants.js';
6
6
  void yargs(hideBin(process.argv))
7
7
  .scriptName(APP_NAME)
8
8
  .strict()
9
- // .usage(Config.usage)
10
9
  .command(create.command, create.description, create.builder, create.handler)
11
10
  .command(build.command, build.description, build.builder, build.handler)
12
11
  .command(run.command, run.description, run.builder, run.handler)
13
12
  .command(info.command, info.description, info.builder, info.handler)
14
13
  .command(check.command, check.description, check.builder, check.handler)
15
14
  .command(showcase.command, showcase.description, showcase.builder, showcase.handler)
15
+ .command(gresource.command, gresource.description, gresource.builder, gresource.handler)
16
+ .command(gettext.command, gettext.description, gettext.builder, gettext.handler)
16
17
  .demandCommand(1)
17
18
  .help().argv;
@@ -54,4 +54,10 @@ export interface CliBuildOptions {
54
54
  * bundle. Only applies to GJS app builds.
55
55
  */
56
56
  globals?: string;
57
+ /**
58
+ * Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output file and mark
59
+ * it executable (chmod 755). Only applies to GJS app builds with a single
60
+ * `--outfile`. Default: false.
61
+ */
62
+ shebang?: boolean;
57
63
  }
@@ -18,4 +18,8 @@ export interface ConfigData {
18
18
  * See CliBuildOptions for format.
19
19
  */
20
20
  globals?: string;
21
+ /**
22
+ * Prepend GJS shebang to output and mark executable. See CliBuildOptions.
23
+ */
24
+ shebang?: boolean;
21
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,18 +23,19 @@
23
23
  "cli"
24
24
  ],
25
25
  "dependencies": {
26
- "@gjsify/create-app": "^0.1.10",
27
- "@gjsify/esbuild-plugin-gjsify": "^0.1.10",
28
- "@gjsify/example-dom-canvas2d-fireworks": "^0.1.10",
29
- "@gjsify/example-dom-excalibur-jelly-jumper": "^0.1.10",
30
- "@gjsify/example-dom-three-geometry-teapot": "^0.1.10",
31
- "@gjsify/example-dom-three-postprocessing-pixel": "^0.1.10",
32
- "@gjsify/example-node-express-webserver": "^0.1.10",
33
- "@gjsify/node-polyfills": "^0.1.10",
34
- "@gjsify/web-polyfills": "^0.1.10",
26
+ "@gjsify/create-app": "^0.1.12",
27
+ "@gjsify/esbuild-plugin-gjsify": "^0.1.12",
28
+ "@gjsify/example-dom-adwaita-package-builder": "^0.1.12",
29
+ "@gjsify/example-dom-canvas2d-fireworks": "^0.1.12",
30
+ "@gjsify/example-dom-excalibur-jelly-jumper": "^0.1.12",
31
+ "@gjsify/example-dom-three-geometry-teapot": "^0.1.12",
32
+ "@gjsify/example-dom-three-postprocessing-pixel": "^0.1.12",
33
+ "@gjsify/example-node-express-webserver": "^0.1.12",
34
+ "@gjsify/node-polyfills": "^0.1.12",
35
+ "@gjsify/web-polyfills": "^0.1.12",
35
36
  "cosmiconfig": "^9.0.1",
36
37
  "esbuild": "^0.28.0",
37
- "get-tsconfig": "^4.13.7",
38
+ "get-tsconfig": "^4.14.0",
38
39
  "pkg-types": "^2.3.0",
39
40
  "yargs": "^18.0.0"
40
41
  },
@@ -3,7 +3,10 @@ import type { App } from '@gjsify/esbuild-plugin-gjsify';
3
3
  import { build, BuildOptions, BuildResult } from 'esbuild';
4
4
  import { gjsifyPlugin } from '@gjsify/esbuild-plugin-gjsify';
5
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals } from '@gjsify/esbuild-plugin-gjsify/globals';
6
- import { dirname, extname } from 'path';
6
+ import { dirname, extname } from 'node:path';
7
+ import { chmod, readFile, writeFile } from 'node:fs/promises';
8
+
9
+ const GJS_SHEBANG = '#!/usr/bin/env -S gjs -m\n';
7
10
 
8
11
  export class BuildAction {
9
12
  constructor(readonly configData: ConfigData = {}) {
@@ -123,6 +126,26 @@ export class BuildAction {
123
126
  return injectPath ?? undefined;
124
127
  }
125
128
 
129
+ /**
130
+ * Post-processing: prepend GJS shebang and mark the output file executable.
131
+ * Only runs for GJS app builds with a resolvable single outfile.
132
+ */
133
+ private async applyShebang(outfile: string | undefined, verbose: boolean | undefined): Promise<void> {
134
+ if (!outfile) {
135
+ if (verbose) console.warn('[gjsify] --shebang skipped: no single outfile (use --outfile for GJS executables)');
136
+ return;
137
+ }
138
+
139
+ const content = await readFile(outfile, 'utf-8');
140
+ if (content.startsWith('#!')) {
141
+ if (verbose) console.debug(`[gjsify] --shebang skipped: ${outfile} already starts with a shebang`);
142
+ } else {
143
+ await writeFile(outfile, GJS_SHEBANG + content);
144
+ }
145
+ await chmod(outfile, 0o755);
146
+ if (verbose) console.debug(`[gjsify] --shebang: wrote shebang + chmod 0o755 to ${outfile}`);
147
+ }
148
+
126
149
  /** Application mode */
127
150
  async buildApp(app: App = 'gjs') {
128
151
 
@@ -172,6 +195,10 @@ export class BuildAction {
172
195
  ],
173
196
  });
174
197
 
198
+ if (app === 'gjs' && this.configData.shebang) {
199
+ await this.applyShebang(esbuild?.outfile, verbose);
200
+ }
201
+
175
202
  return [result];
176
203
  }
177
204
 
@@ -192,6 +219,10 @@ export class BuildAction {
192
219
  ]
193
220
  });
194
221
 
222
+ if (app === 'gjs' && this.configData.shebang) {
223
+ await this.applyShebang(esbuild?.outfile, verbose);
224
+ }
225
+
195
226
  return [result];
196
227
  }
197
228
 
@@ -94,6 +94,12 @@ export const buildCommand: Command<any, CliBuildOptions> = {
94
94
  normalize: true,
95
95
  default: 'auto'
96
96
  })
97
+ .option('shebang', {
98
+ description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile.",
99
+ type: 'boolean',
100
+ normalize: true,
101
+ default: false
102
+ })
97
103
  },
98
104
  handler: async (args) => {
99
105
  const config = new Config();
@@ -1,21 +1,63 @@
1
- import { createProject } from '@gjsify/create-app';
1
+ import { createProject, discoverTemplates } from '@gjsify/create-app';
2
+ import { promptTemplate } from '@gjsify/create-app/prompt';
2
3
  import type { Command } from '../types/index.js';
3
4
 
4
5
  interface CreateOptions {
5
6
  'project-name': string;
7
+ template?: string;
8
+ force: boolean;
9
+ install: boolean;
6
10
  }
7
11
 
8
12
  export const createCommand: Command<any, CreateOptions> = {
9
13
  command: 'create [project-name]',
10
14
  description: 'Scaffold a new Gjsify project in a new directory.',
11
15
  builder: (yargs) => {
12
- return yargs.positional('project-name', {
13
- describe: 'Name of the project directory to create',
14
- type: 'string',
15
- default: 'my-gjs-app',
16
- });
16
+ const templates = discoverTemplates();
17
+ const templateChoices = templates.map((t) => t.name);
18
+ return yargs
19
+ .positional('project-name', {
20
+ describe: 'Name of the project directory to create',
21
+ type: 'string',
22
+ default: 'my-gjs-app',
23
+ })
24
+ .option('template', {
25
+ alias: 't',
26
+ describe: 'Template to scaffold from',
27
+ type: 'string',
28
+ choices: templateChoices.length > 0 ? templateChoices : undefined,
29
+ })
30
+ .option('force', {
31
+ alias: 'f',
32
+ describe: 'Scaffold into a non-empty directory',
33
+ type: 'boolean',
34
+ default: false,
35
+ })
36
+ .option('install', {
37
+ describe: 'Run npm install after scaffolding',
38
+ type: 'boolean',
39
+ default: false,
40
+ });
17
41
  },
18
42
  handler: async (args) => {
19
- await createProject(args['project-name']);
43
+ let template = args.template;
44
+ if (!template) {
45
+ const templates = discoverTemplates();
46
+ if (!process.stdin.isTTY) {
47
+ const list = templates.map((t) => t.name).join(', ');
48
+ console.error(
49
+ `Error: --template is required in non-interactive mode. Available templates: ${list || '(none)'}`,
50
+ );
51
+ process.exit(1);
52
+ }
53
+ const picked = await promptTemplate(templates);
54
+ template = picked.name;
55
+ }
56
+ await createProject({
57
+ projectName: args['project-name'],
58
+ template,
59
+ force: args.force,
60
+ install: args.install,
61
+ });
20
62
  },
21
63
  };
@@ -0,0 +1,258 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import type { Command } from '../types/index.js';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ type GettextFormat = 'mo' | 'xml' | 'desktop' | 'json';
10
+
11
+ interface GettextOptions {
12
+ poDir: string;
13
+ outDir: string;
14
+ domain: string;
15
+ format?: GettextFormat;
16
+ metainfo?: string;
17
+ filename?: string;
18
+ removeXmlComments?: boolean;
19
+ verbose?: boolean;
20
+ }
21
+
22
+ async function listLanguages(poDir: string): Promise<string[]> {
23
+ const entries = await readdir(poDir);
24
+ return entries
25
+ .filter((name) => name.endsWith('.po') && !name.startsWith('.'))
26
+ .map((name) => name.slice(0, -3))
27
+ .sort();
28
+ }
29
+
30
+ function stripXmlComments(source: string): string {
31
+ return source.replace(/<!--[\s\S]*?-->/g, '');
32
+ }
33
+
34
+ async function ensureDir(path: string): Promise<void> {
35
+ await mkdir(path, { recursive: true });
36
+ }
37
+
38
+ async function fileExists(path: string): Promise<boolean> {
39
+ try {
40
+ await stat(path);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function compileBulkXml(opts: {
48
+ poDir: string;
49
+ outDir: string;
50
+ domain: string;
51
+ template: string;
52
+ filename: string;
53
+ removeXmlComments: boolean;
54
+ verbose: boolean;
55
+ }): Promise<void> {
56
+ const outputFile = join(opts.outDir, opts.filename);
57
+ await ensureDir(opts.outDir);
58
+
59
+ const args = [
60
+ `--output-file=${outputFile}`,
61
+ '--xml',
62
+ `--template=${opts.template}`,
63
+ '-d',
64
+ opts.poDir,
65
+ ];
66
+
67
+ if (opts.verbose) {
68
+ console.log(`[gjsify gettext] msgfmt ${args.join(' ')}`);
69
+ }
70
+
71
+ await execFileAsync('msgfmt', args);
72
+
73
+ if (opts.removeXmlComments) {
74
+ const content = await readFile(outputFile, 'utf-8');
75
+ await writeFile(outputFile, stripXmlComments(content));
76
+ }
77
+
78
+ if (opts.verbose) {
79
+ console.log(`[gjsify gettext] wrote ${outputFile}`);
80
+ }
81
+ }
82
+
83
+ async function compilePerLanguage(opts: {
84
+ poDir: string;
85
+ outDir: string;
86
+ domain: string;
87
+ format: GettextFormat;
88
+ filename: string;
89
+ verbose: boolean;
90
+ }): Promise<void> {
91
+ const languages = await listLanguages(opts.poDir);
92
+ if (languages.length === 0) {
93
+ console.warn(`[gjsify gettext] no .po files found in ${opts.poDir}`);
94
+ return;
95
+ }
96
+
97
+ for (const lang of languages) {
98
+ const poFile = join(opts.poDir, `${lang}.po`);
99
+ const langDir =
100
+ opts.format === 'mo'
101
+ ? join(opts.outDir, lang, 'LC_MESSAGES')
102
+ : join(opts.outDir, lang);
103
+ await ensureDir(langDir);
104
+ const outputFile = join(langDir, opts.filename);
105
+
106
+ // msgfmt produces the binary .mo format by default — there is no
107
+ // `--mo` flag (only --xml, --desktop, --properties-output, ...).
108
+ const args = [`--output-file=${outputFile}`];
109
+ if (opts.format !== 'mo') {
110
+ args.push(`--${opts.format}`);
111
+ }
112
+ args.push(poFile);
113
+
114
+ if (opts.verbose) {
115
+ console.log(`[gjsify gettext] msgfmt ${args.join(' ')}`);
116
+ }
117
+
118
+ await execFileAsync('msgfmt', args);
119
+ }
120
+
121
+ if (opts.verbose) {
122
+ console.log(
123
+ `[gjsify gettext] compiled ${languages.length} language(s) into ${opts.outDir}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ function defaultFilename(domain: string, format: GettextFormat, metainfoTemplate?: string): string {
129
+ switch (format) {
130
+ case 'mo':
131
+ return `${domain}.mo`;
132
+ case 'json':
133
+ return `${domain}.json`;
134
+ case 'desktop':
135
+ return `${domain}.desktop`;
136
+ case 'xml': {
137
+ // Mirror the template filename but without the trailing `.in` (convention for
138
+ // pre-processed metainfo templates: `org.foo.Bar.metainfo.xml.in`).
139
+ if (metainfoTemplate) {
140
+ const base = metainfoTemplate.replace(/\.in$/, '');
141
+ const slash = base.lastIndexOf('/');
142
+ return slash >= 0 ? base.slice(slash + 1) : base;
143
+ }
144
+ return `${domain}.xml`;
145
+ }
146
+ }
147
+ }
148
+
149
+ export const gettextCommand: Command<any, GettextOptions> = {
150
+ command: 'gettext <poDir> <outDir>',
151
+ description:
152
+ 'Compile gettext .po files to .mo (per-language locale tree) or substitute a metainfo template via msgfmt --xml.',
153
+ builder: (yargs) => {
154
+ return yargs
155
+ .positional('poDir', {
156
+ description: 'Directory containing <lang>.po files',
157
+ type: 'string',
158
+ normalize: true,
159
+ demandOption: true,
160
+ })
161
+ .positional('outDir', {
162
+ description:
163
+ 'Output directory (locale tree for --format=mo, plain dir for xml/desktop/json)',
164
+ type: 'string',
165
+ normalize: true,
166
+ demandOption: true,
167
+ })
168
+ .option('domain', {
169
+ description: 'Text domain / application ID (e.g. `org.pixelrpg.maker`)',
170
+ type: 'string',
171
+ normalize: true,
172
+ demandOption: true,
173
+ })
174
+ .option('format', {
175
+ description: 'Output format',
176
+ type: 'string',
177
+ choices: ['mo', 'xml', 'desktop', 'json'] as const,
178
+ default: 'mo' as const,
179
+ })
180
+ .option('metainfo', {
181
+ description:
182
+ 'For --format=xml: path to the template (`.metainfo.xml.in`) used as msgfmt --template',
183
+ type: 'string',
184
+ normalize: true,
185
+ })
186
+ .option('filename', {
187
+ description: 'Override the output filename (defaults to <domain>.<ext>)',
188
+ type: 'string',
189
+ normalize: true,
190
+ })
191
+ .option('remove-xml-comments', {
192
+ description: 'For --format=xml: strip XML comments from the compiled output',
193
+ type: 'boolean',
194
+ default: true,
195
+ })
196
+ .option('verbose', {
197
+ description: 'Print each msgfmt invocation',
198
+ type: 'boolean',
199
+ default: false,
200
+ });
201
+ },
202
+ handler: async (args) => {
203
+ const poDir = resolve(args.poDir as string);
204
+ const outDir = resolve(args.outDir as string);
205
+ const domain = args.domain as string;
206
+ const format = (args.format as GettextFormat | undefined) ?? 'mo';
207
+ const metainfo = args.metainfo ? resolve(args.metainfo as string) : undefined;
208
+ const filename = args.filename ?? defaultFilename(domain, format, metainfo);
209
+ const verbose = !!args.verbose;
210
+ const removeXmlComments = !!args['remove-xml-comments'];
211
+
212
+ if (!(await fileExists(poDir))) {
213
+ console.error(`[gjsify gettext] PO directory does not exist: ${poDir}`);
214
+ process.exitCode = 1;
215
+ return;
216
+ }
217
+
218
+ try {
219
+ if (format === 'xml' && metainfo) {
220
+ await compileBulkXml({
221
+ poDir,
222
+ outDir,
223
+ domain,
224
+ template: metainfo,
225
+ filename,
226
+ removeXmlComments,
227
+ verbose,
228
+ });
229
+ } else {
230
+ if (format === 'xml') {
231
+ console.warn(
232
+ '[gjsify gettext] --format=xml without --metainfo: falling back to per-language XML files',
233
+ );
234
+ }
235
+ await compilePerLanguage({
236
+ poDir,
237
+ outDir,
238
+ domain,
239
+ format,
240
+ filename,
241
+ verbose,
242
+ });
243
+ }
244
+ } catch (err: any) {
245
+ if (err?.code === 'ENOENT') {
246
+ console.error(
247
+ '[gjsify gettext] msgfmt not found. Install it via your distro (package: gettext).',
248
+ );
249
+ } else {
250
+ if (err?.stderr) process.stderr.write(err.stderr);
251
+ console.error(
252
+ `[gjsify gettext] msgfmt failed${err?.code !== undefined ? ` (exit ${err.code})` : ''}`,
253
+ );
254
+ }
255
+ process.exitCode = typeof err?.code === 'number' ? err.code : 1;
256
+ }
257
+ },
258
+ };
@@ -0,0 +1,97 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import { basename, dirname, extname, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import type { Command } from '../types/index.js';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ interface GResourceOptions {
10
+ xml: string;
11
+ sourcedir?: string;
12
+ target?: string;
13
+ verbose?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Derive a default `.gresource` target path from the XML descriptor.
18
+ * `foo.gresource.xml` → `foo.gresource` in the same directory.
19
+ */
20
+ function defaultTargetFor(xmlPath: string): string {
21
+ const ext = extname(xmlPath);
22
+ const stem = basename(xmlPath, ext);
23
+ return resolve(dirname(xmlPath), stem);
24
+ }
25
+
26
+ export const gresourceCommand: Command<any, GResourceOptions> = {
27
+ command: 'gresource <xml>',
28
+ description:
29
+ 'Compile a GResource XML descriptor into a binary .gresource bundle (wraps `glib-compile-resources`).',
30
+ builder: (yargs) => {
31
+ return yargs
32
+ .positional('xml', {
33
+ description: 'Path to the .gresource.xml descriptor',
34
+ type: 'string',
35
+ normalize: true,
36
+ demandOption: true,
37
+ })
38
+ .option('sourcedir', {
39
+ description: 'Directory containing the resource files referenced by <xml>',
40
+ type: 'string',
41
+ normalize: true,
42
+ })
43
+ .option('target', {
44
+ alias: 't',
45
+ description: 'Output .gresource file (default: <xml-without-.xml> next to <xml>)',
46
+ type: 'string',
47
+ normalize: true,
48
+ })
49
+ .option('verbose', {
50
+ description: 'Print the underlying glib-compile-resources invocation',
51
+ type: 'boolean',
52
+ default: false,
53
+ });
54
+ },
55
+ handler: async (args) => {
56
+ const xmlPath = resolve(args.xml as string);
57
+ const target = args.target ? resolve(args.target as string) : defaultTargetFor(xmlPath);
58
+ const sourcedir = args.sourcedir
59
+ ? resolve(args.sourcedir as string)
60
+ : dirname(xmlPath);
61
+
62
+ const cmdArgs = [
63
+ `--sourcedir=${sourcedir}`,
64
+ `--target=${target}`,
65
+ xmlPath,
66
+ ];
67
+
68
+ if (args.verbose) {
69
+ console.log(`[gjsify gresource] glib-compile-resources ${cmdArgs.join(' ')}`);
70
+ }
71
+
72
+ // Ensure the target directory exists — glib-compile-resources writes
73
+ // a temporary file next to the target and fails with ENOENT otherwise.
74
+ await mkdir(dirname(target), { recursive: true });
75
+
76
+ try {
77
+ const { stdout, stderr } = await execFileAsync('glib-compile-resources', cmdArgs);
78
+ if (stdout) process.stdout.write(stdout);
79
+ if (stderr) process.stderr.write(stderr);
80
+ if (args.verbose) {
81
+ console.log(`[gjsify gresource] wrote ${target}`);
82
+ }
83
+ } catch (err: any) {
84
+ if (err?.code === 'ENOENT') {
85
+ console.error(
86
+ '[gjsify gresource] glib-compile-resources not found. Install it via your distro (package: glib2-devel / libglib2.0-dev).',
87
+ );
88
+ } else {
89
+ if (err?.stderr) process.stderr.write(err.stderr);
90
+ console.error(
91
+ `[gjsify gresource] glib-compile-resources failed${err?.code !== undefined ? ` (exit ${err.code})` : ''}`,
92
+ );
93
+ }
94
+ process.exitCode = typeof err?.code === 'number' ? err.code : 1;
95
+ }
96
+ },
97
+ };
@@ -3,4 +3,6 @@ export * from './run.js';
3
3
  export * from './info.js';
4
4
  export * from './check.js';
5
5
  export * from './showcase.js';
6
- export * from './create.js';
6
+ export * from './create.js';
7
+ export * from './gresource.js';
8
+ export * from './gettext.js';
package/src/config.ts CHANGED
@@ -83,6 +83,7 @@ export class Config {
83
83
  configData.exclude = cliArgs.exclude || [];
84
84
  if (cliArgs.consoleShim !== undefined) configData.consoleShim = cliArgs.consoleShim;
85
85
  if (cliArgs.globals !== undefined) configData.globals = cliArgs.globals;
86
+ if (cliArgs.shebang !== undefined) configData.shebang = cliArgs.shebang;
86
87
 
87
88
  merge(configData.library ??= {}, pkg, configData.library);
88
89
  merge(configData.typescript ??= {}, tsConfig, configData.typescript);
package/src/index.ts CHANGED
@@ -2,18 +2,28 @@
2
2
  import yargs from 'yargs'
3
3
  import { hideBin } from 'yargs/helpers'
4
4
 
5
- import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create } from './commands/index.js'
5
+ import {
6
+ buildCommand as build,
7
+ runCommand as run,
8
+ infoCommand as info,
9
+ checkCommand as check,
10
+ showcaseCommand as showcase,
11
+ createCommand as create,
12
+ gresourceCommand as gresource,
13
+ gettextCommand as gettext,
14
+ } from './commands/index.js'
6
15
  import { APP_NAME } from './constants.js'
7
16
 
8
17
  void yargs(hideBin(process.argv))
9
18
  .scriptName(APP_NAME)
10
19
  .strict()
11
- // .usage(Config.usage)
12
20
  .command(create.command, create.description, create.builder, create.handler)
13
21
  .command(build.command, build.description, build.builder, build.handler)
14
22
  .command(run.command, run.description, run.builder, run.handler)
15
23
  .command(info.command, info.description, info.builder, info.handler)
16
24
  .command(check.command, check.description, check.builder, check.handler)
17
25
  .command(showcase.command, showcase.description, showcase.builder, showcase.handler)
26
+ .command(gresource.command, gresource.description, gresource.builder, gresource.handler)
27
+ .command(gettext.command, gettext.description, gettext.builder, gettext.handler)
18
28
  .demandCommand(1)
19
29
  .help().argv
@@ -55,4 +55,10 @@ export interface CliBuildOptions {
55
55
  * bundle. Only applies to GJS app builds.
56
56
  */
57
57
  globals?: string;
58
+ /**
59
+ * Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output file and mark
60
+ * it executable (chmod 755). Only applies to GJS app builds with a single
61
+ * `--outfile`. Default: false.
62
+ */
63
+ shebang?: boolean;
58
64
  }
@@ -19,4 +19,8 @@ export interface ConfigData {
19
19
  * See CliBuildOptions for format.
20
20
  */
21
21
  globals?: string;
22
+ /**
23
+ * Prepend GJS shebang to output and mark executable. See CliBuildOptions.
24
+ */
25
+ shebang?: boolean;
22
26
  }