@git.zone/cli 2.19.8 → 2.21.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.
Files changed (28) hide show
  1. package/assets/templates/asset_deno_binary_installer/install.sh +506 -0
  2. package/assets/templates/asset_deno_binary_postinstall/bin/wrapper.js +37 -0
  3. package/assets/templates/asset_deno_binary_postinstall/scripts/install-binary.js +123 -0
  4. package/assets/templates/asset_deno_binary_release_gitea/.gitea/workflows/release.yml +154 -0
  5. package/dist_ts/00_commitinfo_data.js +1 -1
  6. package/dist_ts/helpers.workflow.d.ts +1 -0
  7. package/dist_ts/helpers.workflow.js +2 -1
  8. package/dist_ts/mod_commit/index.js +10 -3
  9. package/dist_ts/mod_config/index.js +91 -1
  10. package/dist_ts/mod_format/classes.baseformatter.d.ts +5 -2
  11. package/dist_ts/mod_format/classes.baseformatter.js +49 -7
  12. package/dist_ts/mod_format/formatters/assets.formatter.d.ts +14 -0
  13. package/dist_ts/mod_format/formatters/assets.formatter.js +384 -0
  14. package/dist_ts/mod_format/formatters/packagejson.formatter.js +40 -2
  15. package/dist_ts/mod_format/index.js +4 -1
  16. package/dist_ts/mod_format/interfaces.format.d.ts +3 -0
  17. package/dist_ts/mod_format/interfaces.format.js +2 -1
  18. package/package.json +4 -4
  19. package/readme.md +95 -0
  20. package/ts/00_commitinfo_data.ts +1 -1
  21. package/ts/helpers.workflow.ts +2 -0
  22. package/ts/mod_commit/index.ts +12 -2
  23. package/ts/mod_config/index.ts +95 -0
  24. package/ts/mod_format/classes.baseformatter.ts +47 -6
  25. package/ts/mod_format/formatters/assets.formatter.ts +451 -0
  26. package/ts/mod_format/formatters/packagejson.formatter.ts +43 -1
  27. package/ts/mod_format/index.ts +3 -0
  28. package/ts/mod_format/interfaces.format.ts +4 -0
@@ -62,7 +62,31 @@ export abstract class BaseFormatter {
62
62
  // Override in subclasses if needed
63
63
  }
64
64
 
65
- protected async modifyFile(filepath: string, content: string): Promise<void> {
65
+ protected normalizeFileMode(mode: string | undefined): string | undefined {
66
+ if (!mode) return undefined;
67
+ const normalizedMode = mode.trim();
68
+ if (!/^[0-7]{3,4}$/.test(normalizedMode)) {
69
+ throw new Error(`Invalid file mode ${mode}`);
70
+ }
71
+ return normalizedMode.padStart(4, '0');
72
+ }
73
+
74
+ protected async getFileMode(filepath: string): Promise<string | undefined> {
75
+ try {
76
+ const stats = await plugins.fs.stat(filepath);
77
+ return (stats.mode & 0o777).toString(8).padStart(4, '0');
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ protected async setFileMode(filepath: string, mode: string): Promise<void> {
84
+ const normalizedMode = this.normalizeFileMode(mode);
85
+ if (!normalizedMode) return;
86
+ await plugins.fs.chmod(filepath, Number.parseInt(normalizedMode, 8));
87
+ }
88
+
89
+ protected async modifyFile(filepath: string, content: string, mode?: string): Promise<void> {
66
90
  if (!filepath || filepath.trim() === '') {
67
91
  throw new Error(`Invalid empty filepath in modifyFile`);
68
92
  }
@@ -73,9 +97,12 @@ export abstract class BaseFormatter {
73
97
  }
74
98
 
75
99
  await plugins.smartfs.file(normalizedPath).encoding('utf8').write(content);
100
+ if (mode) {
101
+ await this.setFileMode(normalizedPath, mode);
102
+ }
76
103
  }
77
104
 
78
- protected async createFile(filepath: string, content: string): Promise<void> {
105
+ protected async createFile(filepath: string, content: string, mode?: string): Promise<void> {
79
106
  let normalizedPath = filepath;
80
107
  if (!plugins.path.parse(filepath).dir) {
81
108
  normalizedPath = './' + filepath;
@@ -88,6 +115,9 @@ export abstract class BaseFormatter {
88
115
  }
89
116
 
90
117
  await plugins.smartfs.file(normalizedPath).encoding('utf8').write(content);
118
+ if (mode) {
119
+ await this.setFileMode(normalizedPath, mode);
120
+ }
91
121
  }
92
122
 
93
123
  protected async deleteFile(filepath: string): Promise<void> {
@@ -115,13 +145,19 @@ export abstract class BaseFormatter {
115
145
  }
116
146
 
117
147
  const newContent = change.content;
148
+ const modeAfter = this.normalizeFileMode(change.mode);
149
+ const modeBefore = modeAfter ? await this.getFileMode(change.path) : undefined;
150
+ const hasContentDiff = currentContent !== newContent && newContent !== undefined;
151
+ const hasModeDiff = modeAfter !== undefined && modeBefore !== modeAfter;
118
152
 
119
- if (currentContent !== newContent && newContent !== undefined) {
153
+ if (hasContentDiff || hasModeDiff) {
120
154
  diffs.push({
121
155
  path: change.path,
122
156
  type: change.type,
123
157
  before: currentContent,
124
158
  after: newContent,
159
+ modeBefore,
160
+ modeAfter,
125
161
  });
126
162
  }
127
163
  } else if (change.type === 'delete') {
@@ -147,21 +183,26 @@ export abstract class BaseFormatter {
147
183
 
148
184
  displayDiff(diff: ICheckResult['diffs'][0]): void {
149
185
  console.log(`\n--- ${diff.path}`);
150
- if (diff.before && diff.after) {
186
+ if (diff.before !== undefined && diff.after !== undefined) {
151
187
  console.log(plugins.smartdiff.formatUnifiedDiffForConsole(diff.before, diff.after, {
152
188
  originalFileName: diff.path,
153
189
  revisedFileName: diff.path,
154
190
  context: 3,
155
191
  }));
156
- } else if (diff.after && !diff.before) {
192
+ } else if (diff.after !== undefined && diff.before === undefined) {
157
193
  console.log(' (new file)');
158
194
  const lines = diff.after.split('\n').slice(0, 10);
159
195
  lines.forEach(line => console.log(` + ${line}`));
160
196
  if (diff.after.split('\n').length > 10) {
161
197
  console.log(' ... (truncated)');
162
198
  }
163
- } else if (diff.before && !diff.after) {
199
+ } else if (diff.type === 'delete') {
164
200
  console.log(' (file will be deleted)');
201
+ } else if (diff.modeAfter) {
202
+ console.log(' (file content unchanged)');
203
+ }
204
+ if (diff.modeBefore !== diff.modeAfter && diff.modeAfter) {
205
+ console.log(` mode: ${diff.modeBefore || '(missing)'} -> ${diff.modeAfter}`);
165
206
  }
166
207
  }
167
208
 
@@ -0,0 +1,451 @@
1
+ import { BaseFormatter } from '../classes.baseformatter.js';
2
+ import type { IFormatWarning, IPlannedChange } from '../interfaces.format.js';
3
+ import * as plugins from '../mod.plugins.js';
4
+ import * as paths from '../../paths.js';
5
+ import { logger, logVerbose } from '../../gitzone.logging.js';
6
+ import { getCliConfigValue, readSmartconfigFile } from '../../helpers.smartconfig.js';
7
+
8
+ interface IAssetFileMapping {
9
+ from: string;
10
+ to: string;
11
+ mode?: string;
12
+ }
13
+
14
+ interface IAssetTemplateEntry {
15
+ template: string;
16
+ files: IAssetFileMapping[];
17
+ enabled?: boolean;
18
+ variables?: Record<string, any>;
19
+ }
20
+
21
+ interface IBinaryTargetRenderData {
22
+ name: string;
23
+ assetName: string;
24
+ os: string;
25
+ arch: string;
26
+ denoTarget: string;
27
+ nodeKey: string;
28
+ label: string;
29
+ }
30
+
31
+ const isPlainObject = (value: unknown): value is Record<string, any> => {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ };
34
+
35
+ const readJsonFile = async (filePath: string): Promise<Record<string, any>> => {
36
+ if (!(await plugins.smartfs.file(filePath).exists())) {
37
+ return {};
38
+ }
39
+ const content = (await plugins.smartfs.file(filePath).encoding('utf8').read()) as string;
40
+ return JSON.parse(content);
41
+ };
42
+
43
+ const uniqueStringArray = (value: unknown, fallback: string[] = []): string[] => {
44
+ if (!Array.isArray(value)) {
45
+ return fallback;
46
+ }
47
+ const result: string[] = [];
48
+ for (const item of value) {
49
+ if (typeof item !== 'string' || !item.trim()) {
50
+ continue;
51
+ }
52
+ if (!result.includes(item)) {
53
+ result.push(item);
54
+ }
55
+ }
56
+ return result;
57
+ };
58
+
59
+ const normalizeRepository = (assetsConfig: Record<string, any>, moduleConfig: Record<string, any>) => {
60
+ const repository = isPlainObject(assetsConfig.repository) ? assetsConfig.repository : {};
61
+ const host = repository.host || moduleConfig.githost || 'code.foss.global';
62
+ const path = repository.path || [moduleConfig.gitscope, moduleConfig.gitrepo].filter(Boolean).join('/');
63
+ const branch = repository.branch || repository.rawBranch || assetsConfig.rawBranch || 'main';
64
+ const baseUrl = `https://${host}/${path}`;
65
+ return {
66
+ host,
67
+ path,
68
+ branch,
69
+ baseUrl,
70
+ apiBaseUrl: `https://${host}/api/v1/repos/${path}`,
71
+ gitUrl: `${baseUrl}.git`,
72
+ };
73
+ };
74
+
75
+ const normalizeOs = (target: Record<string, any>): string => {
76
+ const rawValue = `${target.os || ''} ${target.target || ''} ${target.name || ''}`.toLowerCase();
77
+ if (rawValue.includes('apple') || rawValue.includes('darwin') || rawValue.includes('macos')) return 'macos';
78
+ if (rawValue.includes('windows') || rawValue.includes('pc-windows') || rawValue.includes('-win')) return 'windows';
79
+ return 'linux';
80
+ };
81
+
82
+ const normalizeArch = (target: Record<string, any>): string => {
83
+ const rawValue = `${target.arch || ''} ${target.target || ''} ${target.name || ''}`.toLowerCase();
84
+ if (rawValue.includes('aarch64') || rawValue.includes('arm64')) return 'arm64';
85
+ return 'x64';
86
+ };
87
+
88
+ const getNodePlatform = (os: string): string => {
89
+ if (os === 'macos') return 'darwin';
90
+ if (os === 'windows') return 'win32';
91
+ return 'linux';
92
+ };
93
+
94
+ const getPlatformLabel = (target: IBinaryTargetRenderData): string => {
95
+ const osLabel = target.os === 'macos' ? 'macOS' : target.os === 'windows' ? 'Windows' : 'Linux';
96
+ const archLabel = target.arch === 'arm64' ? 'ARM64' : 'x64';
97
+ return `${osLabel} ${archLabel}`;
98
+ };
99
+
100
+ const normalizeBinaryTargets = (
101
+ assetsConfig: Record<string, any>,
102
+ smartconfigData: Record<string, any>,
103
+ ): IBinaryTargetRenderData[] => {
104
+ const binaryConfig = isPlainObject(assetsConfig.binary) ? assetsConfig.binary : {};
105
+ const tsdenoConfig = smartconfigData['@git.zone/tsdeno'];
106
+ const targets = Array.isArray(binaryConfig.targets)
107
+ ? binaryConfig.targets
108
+ : isPlainObject(tsdenoConfig) && Array.isArray(tsdenoConfig.compileTargets)
109
+ ? tsdenoConfig.compileTargets
110
+ : [];
111
+
112
+ return targets
113
+ .filter((target) => isPlainObject(target) && typeof target.name === 'string')
114
+ .map((target) => {
115
+ const os = normalizeOs(target);
116
+ const arch = normalizeArch(target);
117
+ const isWindows = os === 'windows';
118
+ const assetName = isWindows && !target.name.endsWith('.exe') ? `${target.name}.exe` : target.name;
119
+ const result: IBinaryTargetRenderData = {
120
+ name: target.name,
121
+ assetName,
122
+ os,
123
+ arch,
124
+ denoTarget: target.target || target.denoTarget || '',
125
+ nodeKey: `${getNodePlatform(os)}:${arch}`,
126
+ label: '',
127
+ };
128
+ result.label = getPlatformLabel(result);
129
+ return result;
130
+ });
131
+ };
132
+
133
+ const buildDefaultEntries = (assetsConfig: Record<string, any>, cliName: string): IAssetTemplateEntry[] => {
134
+ if (assetsConfig.kind !== 'denoBinaryCli') {
135
+ return [];
136
+ }
137
+
138
+ const entries: IAssetTemplateEntry[] = [];
139
+ const installerConfig = isPlainObject(assetsConfig.installer) ? assetsConfig.installer : {};
140
+ const npmWrapperConfig = isPlainObject(assetsConfig.npmWrapper) ? assetsConfig.npmWrapper : {};
141
+ const releaseWorkflowConfig = isPlainObject(assetsConfig.releaseWorkflow)
142
+ ? assetsConfig.releaseWorkflow
143
+ : isPlainObject(assetsConfig.releaseAssets)
144
+ ? assetsConfig.releaseAssets
145
+ : {};
146
+
147
+ if (installerConfig.enabled !== false) {
148
+ entries.push({
149
+ template: 'asset_deno_binary_installer',
150
+ files: [{ from: 'install.sh', to: installerConfig.file || 'install.sh', mode: '0755' }],
151
+ });
152
+ }
153
+
154
+ if (npmWrapperConfig.enabled === true) {
155
+ entries.push({
156
+ template: 'asset_deno_binary_postinstall',
157
+ files: [
158
+ {
159
+ from: 'scripts/install-binary.js',
160
+ to: npmWrapperConfig.postinstallFile || 'scripts/install-binary.js',
161
+ mode: '0755',
162
+ },
163
+ {
164
+ from: 'bin/wrapper.js',
165
+ to: npmWrapperConfig.binFile || `bin/${cliName}-wrapper.js`,
166
+ mode: '0755',
167
+ },
168
+ ],
169
+ });
170
+ }
171
+
172
+ if (releaseWorkflowConfig.enabled !== false) {
173
+ entries.push({
174
+ template: 'asset_deno_binary_release_gitea',
175
+ files: [{ from: '.gitea/workflows/release.yml', to: releaseWorkflowConfig.file || '.gitea/workflows/release.yml' }],
176
+ });
177
+ }
178
+
179
+ return entries;
180
+ };
181
+
182
+ export class AssetsFormatter extends BaseFormatter {
183
+ get name(): string {
184
+ return 'assets';
185
+ }
186
+
187
+ async analyze(): Promise<IPlannedChange[]> {
188
+ const assetsConfig = await getCliConfigValue<Record<string, any> | undefined>('assets', undefined);
189
+ if (!isPlainObject(assetsConfig) || assetsConfig.enabled === false) {
190
+ logVerbose('No @git.zone/cli.assets config found, skipping');
191
+ return [];
192
+ }
193
+
194
+ const smartconfigData = await readSmartconfigFile(paths.cwd);
195
+ const renderData = await this.buildRenderData(assetsConfig, smartconfigData);
196
+ const entries = this.getTemplateEntries(assetsConfig, renderData.cliName);
197
+ const changes: IPlannedChange[] = [];
198
+
199
+ for (const entry of entries) {
200
+ if (entry.enabled === false) {
201
+ continue;
202
+ }
203
+ const renderedFiles = await this.renderTemplate(entry.template, {
204
+ ...renderData,
205
+ ...(entry.variables || {}),
206
+ });
207
+
208
+ for (const file of entry.files || []) {
209
+ const content = renderedFiles.get(file.from) || renderedFiles.get(file.to);
210
+ if (content === undefined) {
211
+ throw new Error(`Managed asset ${entry.template}/${file.from} did not render a file`);
212
+ }
213
+
214
+ const destExists = await plugins.smartfs.file(file.to).exists();
215
+ const currentContent = destExists
216
+ ? ((await plugins.smartfs.file(file.to).encoding('utf8').read()) as string)
217
+ : '';
218
+ const normalizedMode = this.normalizeFileMode(file.mode);
219
+ const currentMode = normalizedMode ? await this.getFileMode(file.to) : undefined;
220
+ const needsContentChange = content !== currentContent;
221
+ const needsModeChange = normalizedMode !== undefined && currentMode !== normalizedMode;
222
+
223
+ if (needsContentChange || needsModeChange) {
224
+ changes.push({
225
+ type: destExists ? 'modify' : 'create',
226
+ path: file.to,
227
+ module: this.name,
228
+ description: `Apply managed asset ${entry.template}/${file.from}`,
229
+ content: needsContentChange ? content : undefined,
230
+ mode: normalizedMode,
231
+ });
232
+ }
233
+ }
234
+ }
235
+
236
+ return changes;
237
+ }
238
+
239
+ async validate(): Promise<IFormatWarning[]> {
240
+ const assetsConfig = await getCliConfigValue<Record<string, any> | undefined>('assets', undefined);
241
+ if (!isPlainObject(assetsConfig) || assetsConfig.enabled === false) {
242
+ return [];
243
+ }
244
+ if (assetsConfig.kind && assetsConfig.kind !== 'denoBinaryCli') {
245
+ return [{ level: 'error', module: this.name, message: `Unsupported managed asset kind: ${assetsConfig.kind}` }];
246
+ }
247
+ const installer = isPlainObject(assetsConfig.installer) ? assetsConfig.installer : {};
248
+ if (installer.distribution && !['rawBranch', 'releaseAsset'].includes(installer.distribution)) {
249
+ return [
250
+ {
251
+ level: 'error',
252
+ module: this.name,
253
+ message: `Unsupported installer distribution: ${installer.distribution}`,
254
+ },
255
+ ];
256
+ }
257
+ return [];
258
+ }
259
+
260
+ async applyChange(change: IPlannedChange): Promise<void> {
261
+ if (change.type === 'create') {
262
+ await this.createFile(change.path, change.content || '', change.mode);
263
+ } else if (change.type === 'modify') {
264
+ if (change.content !== undefined) {
265
+ await this.modifyFile(change.path, change.content, change.mode);
266
+ } else if (change.mode) {
267
+ await this.setFileMode(change.path, change.mode);
268
+ }
269
+ }
270
+ logger.log('info', `Applied managed asset to ${change.path}`);
271
+ }
272
+
273
+ private getTemplateEntries(assetsConfig: Record<string, any>, cliName: string): IAssetTemplateEntry[] {
274
+ if (Array.isArray(assetsConfig.managed)) {
275
+ return assetsConfig.managed
276
+ .filter((entry) => isPlainObject(entry) && typeof entry.template === 'string')
277
+ .map((entry) => ({
278
+ template: entry.template,
279
+ enabled: entry.enabled,
280
+ variables: isPlainObject(entry.variables) ? entry.variables : undefined,
281
+ files: Array.isArray(entry.files)
282
+ ? entry.files
283
+ .filter((file) => isPlainObject(file) && typeof file.from === 'string' && typeof file.to === 'string')
284
+ .map((file) => ({ from: file.from, to: file.to, mode: file.mode }))
285
+ : [],
286
+ }));
287
+ }
288
+ return buildDefaultEntries(assetsConfig, cliName);
289
+ }
290
+
291
+ private async renderTemplate(templateName: string, variables: Record<string, any>): Promise<Map<string, string>> {
292
+ const templateDir = plugins.path.join(paths.templatesDir, templateName);
293
+ if (!(await plugins.smartfs.directory(templateDir).exists())) {
294
+ throw new Error(`Managed asset template does not exist: ${templateName}`);
295
+ }
296
+
297
+ const scafTemplate = new plugins.smartscaf.ScafTemplate(templateDir);
298
+ await scafTemplate.readTemplateFromDir();
299
+ await scafTemplate.supplyVariables(variables);
300
+
301
+ const renderedFiles = await scafTemplate.renderToMemory();
302
+ const fileMap = new Map<string, string>();
303
+ for (const file of renderedFiles) {
304
+ fileMap.set(file.path, file.contents.toString());
305
+ }
306
+ return fileMap;
307
+ }
308
+
309
+ private async buildRenderData(
310
+ assetsConfig: Record<string, any>,
311
+ smartconfigData: Record<string, any>,
312
+ ): Promise<Record<string, any>> {
313
+ const packageJson = await readJsonFile(plugins.path.join(paths.cwd, 'package.json'));
314
+ const denoJson = await readJsonFile(plugins.path.join(paths.cwd, 'deno.json'));
315
+ const moduleConfig = this.project.gitzoneConfig?.data?.module || {};
316
+ const cliName = assetsConfig.cliName || packageJson.name?.split('/').pop() || moduleConfig.gitrepo || 'app';
317
+ const displayName = assetsConfig.displayName || cliName.toUpperCase();
318
+ const repository = normalizeRepository(assetsConfig, moduleConfig);
319
+ const npmPackageName = (moduleConfig as any).npmPackagename || moduleConfig.npmPackageName;
320
+ const binaryTargets = normalizeBinaryTargets(assetsConfig, smartconfigData);
321
+ const binaryConfig = isPlainObject(assetsConfig.binary) ? assetsConfig.binary : {};
322
+ const installerConfig = this.normalizeInstallerConfig(assetsConfig, cliName, repository);
323
+ const npmWrapperConfig = this.normalizeNpmWrapperConfig(assetsConfig, cliName);
324
+ const releaseWorkflowConfig = this.normalizeReleaseWorkflowConfig(assetsConfig, binaryConfig, installerConfig);
325
+
326
+ return {
327
+ module: moduleConfig,
328
+ projectType: this.project.gitzoneConfig?.data?.projectType,
329
+ packageJson,
330
+ denoJson,
331
+ assets: assetsConfig,
332
+ cliName,
333
+ cliNameUpper: cliName.toUpperCase(),
334
+ displayName,
335
+ packageName: assetsConfig.packageName || packageJson.name || npmPackageName || cliName,
336
+ versionFallback: packageJson.version || denoJson.version || '0.0.0',
337
+ repository,
338
+ binary: {
339
+ ...binaryConfig,
340
+ outDir: binaryConfig.outDir || 'dist/binaries',
341
+ installFileName: binaryConfig.installFileName || cliName,
342
+ targets: binaryTargets,
343
+ supportedPlatformText: binaryTargets.map((target) => target.label).join(', '),
344
+ hasTargets: binaryTargets.length > 0,
345
+ },
346
+ installer: installerConfig,
347
+ npmWrapper: npmWrapperConfig,
348
+ releaseWorkflow: releaseWorkflowConfig,
349
+ };
350
+ }
351
+
352
+ private normalizeInstallerConfig(
353
+ assetsConfig: Record<string, any>,
354
+ cliName: string,
355
+ repository: Record<string, any>,
356
+ ): Record<string, any> {
357
+ const installer = isPlainObject(assetsConfig.installer) ? assetsConfig.installer : {};
358
+ const modes = isPlainObject(installer.modes) ? installer.modes : {};
359
+ const source = isPlainObject(modes.source) ? modes.source : isPlainObject(installer.source) ? installer.source : {};
360
+ const service = isPlainObject(installer.service) ? installer.service : {};
361
+ const file = installer.file || 'install.sh';
362
+ const releaseAssetName = plugins.path.basename(file);
363
+ const distribution = installer.distribution || installer.delivery || 'rawBranch';
364
+ const versionSelection = uniqueStringArray(installer.versionSelection, ['latest', 'version']);
365
+ const detectNames = uniqueStringArray(service.detectNames, service.name ? [service.name] : [cliName]);
366
+ const removeLegacyUnits = uniqueStringArray(service.removeLegacyUnits || service.legacyUnitNames || []);
367
+ const ensureDirs = uniqueStringArray(installer.ensureDirs);
368
+ const preservePaths = uniqueStringArray(installer.preservePaths);
369
+ const successHints = uniqueStringArray(installer.successHints, [`${cliName} --version`, `${cliName} --help`]);
370
+ const rawBranchInstallUrl = `${repository.baseUrl}/raw/branch/${repository.branch}/${file}`;
371
+ const releaseAssetInstallUrl = `${repository.baseUrl}/releases/download/<version>/${releaseAssetName}`;
372
+ const releaseNotesInstallUrl = `${repository.baseUrl}/releases/download/$VERSION/${releaseAssetName}`;
373
+
374
+ return {
375
+ ...installer,
376
+ enabled: installer.enabled !== false,
377
+ file,
378
+ distribution,
379
+ releaseAssetName,
380
+ publicInstallUrl: distribution === 'releaseAsset' ? releaseAssetInstallUrl : rawBranchInstallUrl,
381
+ releaseNotesInstallUrl: distribution === 'releaseAsset' ? releaseNotesInstallUrl : rawBranchInstallUrl,
382
+ installDir: installer.installDir || `/opt/${cliName}`,
383
+ binDir: installer.binDir || '/usr/local/bin',
384
+ defaultMode: modes.default || installer.defaultMode || 'binary',
385
+ versionSelection,
386
+ supportsMajor: versionSelection.includes('major'),
387
+ source: {
388
+ ...source,
389
+ enabled: source.enabled === true,
390
+ commands: uniqueStringArray(source.commands),
391
+ executable: source.executable || 'cli.js',
392
+ executableFiles: uniqueStringArray(source.executableFiles, source.executable ? [source.executable] : ['cli.js']),
393
+ validateCommand: source.validate || source.validateCommand || `node ${source.executable || 'cli.js'} --version`,
394
+ },
395
+ service: {
396
+ ...service,
397
+ detectNames,
398
+ removeLegacyUnits,
399
+ hasLegacyUnitRemoval: removeLegacyUnits.length > 0,
400
+ restartName: service.restartName || detectNames[0] || cliName,
401
+ hasRefreshCommand: typeof service.refreshCommand === 'string' && service.refreshCommand.trim().length > 0,
402
+ },
403
+ ensureDirs,
404
+ preservePaths,
405
+ primaryPreservePath: preservePaths[0],
406
+ hasPreservePaths: preservePaths.length > 0,
407
+ successHints,
408
+ };
409
+ }
410
+
411
+ private normalizeNpmWrapperConfig(assetsConfig: Record<string, any>, cliName: string): Record<string, any> {
412
+ const npmWrapper = isPlainObject(assetsConfig.npmWrapper) ? assetsConfig.npmWrapper : {};
413
+ return {
414
+ ...npmWrapper,
415
+ enabled: npmWrapper.enabled === true,
416
+ binFile: npmWrapper.binFile || `bin/${cliName}-wrapper.js`,
417
+ postinstallFile: npmWrapper.postinstallFile || 'scripts/install-binary.js',
418
+ binariesDir: npmWrapper.binariesDir || 'dist/binaries',
419
+ };
420
+ }
421
+
422
+ private normalizeReleaseWorkflowConfig(
423
+ assetsConfig: Record<string, any>,
424
+ binaryConfig: Record<string, any>,
425
+ installerConfig: Record<string, any>,
426
+ ): Record<string, any> {
427
+ const releaseWorkflow = isPlainObject(assetsConfig.releaseWorkflow)
428
+ ? assetsConfig.releaseWorkflow
429
+ : isPlainObject(assetsConfig.releaseAssets)
430
+ ? assetsConfig.releaseAssets
431
+ : {};
432
+ const binaryOutDir = binaryConfig.outDir || 'dist/binaries';
433
+ const assetGlobs = uniqueStringArray(releaseWorkflow.assetGlobs || releaseWorkflow.include, [`${binaryOutDir}/*`]);
434
+ const includeInstallerAsset =
435
+ typeof releaseWorkflow.includeInstallerAsset === 'boolean'
436
+ ? releaseWorkflow.includeInstallerAsset
437
+ : installerConfig.enabled !== false && installerConfig.distribution === 'releaseAsset';
438
+ return {
439
+ ...releaseWorkflow,
440
+ enabled: releaseWorkflow.enabled !== false,
441
+ file: releaseWorkflow.file || '.gitea/workflows/release.yml',
442
+ installCommand: releaseWorkflow.installCommand || 'pnpm install --ignore-scripts',
443
+ compileCommand: releaseWorkflow.compileCommand || 'pnpm exec tsdeno compile',
444
+ packNpmArtifact: releaseWorkflow.packNpmArtifact === true,
445
+ includeInstallerAsset,
446
+ assetGlobs,
447
+ keepReleases: releaseWorkflow.keepReleases ?? 0,
448
+ binaryOutDir,
449
+ };
450
+ }
451
+ }
@@ -70,8 +70,41 @@ export class PackageJsonFormatter extends BaseFormatter {
70
70
  packageJson.scripts.build = `echo "Not needed for now"`;
71
71
  }
72
72
 
73
+ const rawAssetsConfig = gitzoneData.assets;
74
+ const assetsConfig = rawAssetsConfig?.enabled === false ? undefined : rawAssetsConfig;
75
+ const managedAssetConfig = rawAssetsConfig?.kind === 'denoBinaryCli' ? rawAssetsConfig : undefined;
76
+ const managedCliName = managedAssetConfig?.cliName || packageJson.name?.split('/').pop();
77
+ const managedBinFile = managedAssetConfig?.npmWrapper?.binFile || `bin/${managedCliName}-wrapper.js`;
78
+ const managedPostinstallFile = managedAssetConfig?.npmWrapper?.postinstallFile || 'scripts/install-binary.js';
79
+ const managedPostinstallCommand = `node ${managedPostinstallFile}`;
80
+
81
+ if (assetsConfig?.kind === 'denoBinaryCli' && assetsConfig.npmWrapper?.enabled) {
82
+ if (managedCliName) {
83
+ packageJson.bin = typeof packageJson.bin === 'object' && !Array.isArray(packageJson.bin)
84
+ ? packageJson.bin
85
+ : {};
86
+ packageJson.bin[managedCliName] = `./${managedBinFile}`;
87
+ }
88
+ packageJson.scripts.postinstall = managedPostinstallCommand;
89
+ } else if (managedAssetConfig) {
90
+ if (
91
+ managedCliName &&
92
+ typeof packageJson.bin === 'object' &&
93
+ !Array.isArray(packageJson.bin) &&
94
+ packageJson.bin[managedCliName] === `./${managedBinFile}`
95
+ ) {
96
+ delete packageJson.bin[managedCliName];
97
+ if (Object.keys(packageJson.bin).length === 0) {
98
+ delete packageJson.bin;
99
+ }
100
+ }
101
+ if (packageJson.scripts.postinstall === managedPostinstallCommand) {
102
+ delete packageJson.scripts.postinstall;
103
+ }
104
+ }
105
+
73
106
  // Set files array
74
- packageJson.files = [
107
+ const files = [
75
108
  'ts/**/*',
76
109
  'ts_web/**/*',
77
110
  'dist/**/*',
@@ -84,6 +117,15 @@ export class PackageJsonFormatter extends BaseFormatter {
84
117
  'readme.md',
85
118
  ];
86
119
 
120
+ if (assetsConfig?.kind === 'denoBinaryCli' && assetsConfig.installer?.enabled !== false) {
121
+ files.push(assetsConfig.installer?.file || 'install.sh');
122
+ }
123
+ if (assetsConfig?.kind === 'denoBinaryCli' && assetsConfig.npmWrapper?.enabled) {
124
+ files.push('bin/');
125
+ files.push(assetsConfig.npmWrapper.postinstallFile || 'scripts/install-binary.js');
126
+ }
127
+ packageJson.files = [...new Set(files)];
128
+
87
129
  // Set pnpm overrides from assets
88
130
  try {
89
131
  const overridesContent = (await plugins.smartfs
@@ -17,6 +17,7 @@ import { SmartconfigFormatter } from "./formatters/smartconfig.formatter.js";
17
17
  import { LicenseFormatter } from "./formatters/license.formatter.js";
18
18
  import { PackageJsonFormatter } from "./formatters/packagejson.formatter.js";
19
19
  import { TemplatesFormatter } from "./formatters/templates.formatter.js";
20
+ import { AssetsFormatter } from "./formatters/assets.formatter.js";
20
21
  import { GitignoreFormatter } from "./formatters/gitignore.formatter.js";
21
22
  import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js";
22
23
  import { PrettierFormatter } from "./formatters/prettier.formatter.js";
@@ -61,6 +62,7 @@ const formatterMap: Record<
61
62
  license: LicenseFormatter,
62
63
  packagejson: PackageJsonFormatter,
63
64
  templates: TemplatesFormatter,
65
+ assets: AssetsFormatter,
64
66
  gitignore: GitignoreFormatter,
65
67
  tsconfig: TsconfigFormatter,
66
68
  prettier: PrettierFormatter,
@@ -206,6 +208,7 @@ const serializePlan = (plan: any) => {
206
208
  path: change.path,
207
209
  module: change.module,
208
210
  description: change.description,
211
+ mode: change.mode,
209
212
  })),
210
213
  };
211
214
  };
@@ -21,6 +21,7 @@ export type IPlannedChange = {
21
21
  module: string;
22
22
  description: string;
23
23
  content?: string; // New content for create/modify operations
24
+ mode?: string; // Optional file mode, e.g. 0755 for generated executables
24
25
  };
25
26
 
26
27
  export interface ICheckResult {
@@ -30,6 +31,8 @@ export interface ICheckResult {
30
31
  type: 'create' | 'modify' | 'delete';
31
32
  before?: string;
32
33
  after?: string;
34
+ modeBefore?: string;
35
+ modeAfter?: string;
33
36
  }>;
34
37
  }
35
38
 
@@ -44,6 +47,7 @@ export function getModuleIcon(module: string): string {
44
47
  readme: '📖',
45
48
  templates: '📄',
46
49
  smartconfig: '⚙️',
50
+ assets: '🧩',
47
51
  copy: '📋',
48
52
  };
49
53
  return icons[module] || '📁';