@haptiq/kit 0.5.0 → 0.7.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.
package/README.md CHANGED
@@ -52,71 +52,38 @@ kit js --skip <name> # skip a named config (multi-config mode)
52
52
 
53
53
  **Defaults:** reads `src/**/*.js`, writes to `js/bundle.js`.
54
54
 
55
- ## Configuration
55
+ ---
56
56
 
57
- Create a `haptiq.config.js` in your project root. All options are optional.
58
-
59
- ```js
60
- module.exports = {
61
- css: {
62
- src: 'assets/**/*.{scss,sass,css}', // default: 'src/**/*.{scss,sass,css}'
63
- dest: 'public/css', // default: 'css'
64
- sass: {
65
- style: 'expanded',
66
- includePaths: ['node_modules'],
67
- },
68
- lightning: {
69
- targets: { chrome: 90, firefox: 88, safari: 14 },
70
- minify: true,
71
- sourceMap: true,
72
- },
73
- },
74
-
75
- // Combine mode — all files into one output
76
- js: {
77
- src: 'assets/**/*.js', // default: 'src/**/*.js'
78
- dest: 'public/js/bundle.js', // default: 'js/bundle.js'
79
- terser: {
80
- compress: {
81
- drop_console: true,
82
- drop_debugger: true,
83
- },
84
- format: {
85
- comments: false,
86
- },
87
- },
88
- },
89
- };
90
- ```
57
+ ### `kit ship [target]`
91
58
 
92
- ### Multiple JS configs
93
-
94
- Use `js.configs` to define several named configurations:
95
-
96
- ```js
97
- module.exports = {
98
- js: {
99
- configs: {
100
- 'app': {
101
- src: 'src/app/**/*.js',
102
- dest: 'public/js/app.bundle.js',
103
- },
104
- 'components': {
105
- src: 'src/components/**/*.js',
106
- dest: 'public/js/components/',
107
- },
108
- },
109
- },
110
- };
111
- ```
112
-
113
- Then use `--only` / `--skip` to target specific configs:
59
+ Builds CSS and JS assets, then syncs them to a destination via rsync or packages them as a zip archive.
114
60
 
115
61
  ```sh
116
- kit js --only app
117
- kit js --skip components
62
+ kit ship # prompts you to choose a target
63
+ kit ship staging # ship to a specific named target
64
+ kit ship dist # built-in local target (always available, no config needed)
65
+ kit ship --dev # build without minification before shipping
66
+ kit ship --verbose # show detailed rsync/zip output
118
67
  ```
119
68
 
69
+ When called without a target name, the command lists all configured targets and asks you to choose one before proceeding. To skip the prompt, pass the target name directly.
70
+
71
+ `kit ship dist` syncs the project to a sibling directory (`../project-name-dist/`) and is always available without any configuration.
72
+
73
+ **Target types**
74
+
75
+ | Type | Config | Behaviour |
76
+ |---|---|---|
77
+ | Remote | `host` + `dest` | rsyncs `src` to `host:dest` |
78
+ | Local | neither | rsyncs `src` to `../project-name-dist/` (same as `kit ship dist`) |
79
+ | Zip | `zip` path | packages `src` into a zip archive |
80
+
81
+ `zip` is mutually exclusive with `host` and `dest`. The zip destination directory must exist; the command aborts if the archive already exists (remove it manually to re-ship).
82
+
83
+ ## Configuration
84
+
85
+ Create a `haptiq.config.js` in your project root. All options are optional. See [`examples/haptiq.config.js`](examples/haptiq.config.js) for a full annotated reference.
86
+
120
87
  ## License
121
88
 
122
89
  GPL-2.0-or-later
package/bin/haptiq.kit.js CHANGED
@@ -87,6 +87,14 @@ async function loadConfig(verbose = false) {
87
87
  throw new Error('ship configuration must be an object');
88
88
  }
89
89
 
90
+ if (config.ship.src !== undefined && (typeof config.ship.src !== 'string' || config.ship.src.trim() === '')) {
91
+ throw new Error('ship.src must be a non-empty string');
92
+ }
93
+
94
+ if (config.ship.exclude !== undefined && !Array.isArray(config.ship.exclude)) {
95
+ throw new Error('ship.exclude must be an array');
96
+ }
97
+
90
98
  if (config.ship.targets) {
91
99
  if (typeof config.ship.targets !== 'object' || Array.isArray(config.ship.targets)) {
92
100
  throw new Error('ship.targets must be an object');
@@ -102,6 +110,15 @@ async function loadConfig(verbose = false) {
102
110
  if (targetConfig.dev !== undefined && typeof targetConfig.dev !== 'boolean') {
103
111
  throw new Error(`ship.targets.${targetName}.dev must be a boolean (true or false, not a string)`);
104
112
  }
113
+ if (targetConfig.zip !== undefined && typeof targetConfig.zip !== 'string') {
114
+ throw new Error(`ship.targets.${targetName}.zip must be a string`);
115
+ }
116
+ if (targetConfig.zip && targetConfig.host) {
117
+ throw new Error(`ship.targets.${targetName}: "zip" and "host" are mutually exclusive`);
118
+ }
119
+ if (targetConfig.zip && targetConfig.dest) {
120
+ throw new Error(`ship.targets.${targetName}: "zip" and "dest" are mutually exclusive`);
121
+ }
105
122
  }
106
123
  }
107
124
  }
@@ -134,8 +151,8 @@ async function loadConfig(verbose = false) {
134
151
  */
135
152
  function createCommandHandler(taskName, taskFunction) {
136
153
  return async (...args) => {
137
- const options = args[args.length - 1];
138
- const positionalArgs = args.slice(0, -1);
154
+ const options = args[args.length - 2];
155
+ const positionalArgs = args.slice(0, -2);
139
156
  try {
140
157
  const config = await loadConfig(options.verbose);
141
158
  await taskFunction(config, options, ...positionalArgs);
package/lib/ship.js CHANGED
@@ -6,9 +6,11 @@
6
6
  * under the `ship` key.
7
7
  */
8
8
  import { spawn } from 'child_process';
9
- import { existsSync } from 'fs';
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
10
10
  import readline from 'readline';
11
11
  import path from 'path';
12
+ import { glob } from 'glob';
13
+ import { zipSync } from 'fflate';
12
14
  import { buildCSS } from './css.js';
13
15
  import { buildJS } from './js.js';
14
16
 
@@ -45,22 +47,96 @@ async function ship(config, verbose, options = {}) {
45
47
  if (configuredTargetNames.length === 0) {
46
48
  throw new Error('No ship targets configured. Add a ship.targets object to haptiq.config.js, or run `kit ship dist` for a local build.');
47
49
  }
48
- targetsToProcess = configuredTargetNames;
50
+ const selected = await promptTarget(configuredTargetNames);
51
+ targetsToProcess = [selected];
49
52
  }
50
53
 
51
54
  for (const name of targetsToProcess) {
52
55
  const targetConfig = targets[name] ?? {};
53
- const dev = cliDev || targetConfig.dev || false;
56
+ const dev = (cliDev ?? targetConfig.dev) ?? false;
54
57
 
55
58
  await buildCSS(config, verbose, dev);
56
59
  await buildJS(config, verbose, { dev });
57
- await shipSingleTarget(name, targetConfig, { src, exclude, deleteRemoved }, verbose);
60
+
61
+ if (targetConfig.zip) {
62
+ await zipSingleTarget(name, targetConfig, { src, exclude }, verbose);
63
+ } else {
64
+ await shipSingleTarget(name, targetConfig, { src, exclude, deleteRemoved }, verbose);
65
+ }
58
66
 
59
67
  console.log(`✅ Shipped to [${name}].`);
60
68
  }
61
69
  }
62
70
 
63
71
 
72
+ /**
73
+ * Zip a single target — builds a zip archive at the configured path.
74
+ * Aborts if the destination directory does not exist or the zip file already exists.
75
+ *
76
+ * @param {string} name - Target name (for log messages)
77
+ * @param {{ zip: string }} targetConfig - Target settings
78
+ * @param {{ src: string, exclude: string[] }} shipConfig - Shared settings
79
+ * @param {boolean} verbose - List each file added to the archive
80
+ * @returns {Promise<void>}
81
+ */
82
+ async function zipSingleTarget(name, targetConfig, shipConfig, verbose) {
83
+ const { zip: zipPathTemplate } = targetConfig;
84
+ const { src = './', exclude = [] } = shipConfig;
85
+
86
+ const projectRoot = process.cwd();
87
+ const projectName = path.basename(projectRoot);
88
+
89
+ let version = '0.0.0';
90
+ const pkgPath = path.join(projectRoot, 'package.json');
91
+ if (existsSync(pkgPath)) {
92
+ try {
93
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
94
+ version = pkg.version ?? version;
95
+ } catch { /* use default */ }
96
+ }
97
+
98
+ const resolvedZipPath = zipPathTemplate
99
+ .replace(/\{name\}/g, projectName)
100
+ .replace(/\{version\}/g, version);
101
+
102
+ const zipDir = path.dirname(resolvedZipPath);
103
+ if (!existsSync(zipDir)) {
104
+ throw new Error(`Destination directory does not exist: ${zipDir}\nCreate it manually before shipping.`);
105
+ }
106
+
107
+ if (existsSync(resolvedZipPath)) {
108
+ throw new Error(`Zip archive already exists: ${resolvedZipPath}\nRemove it manually before shipping to avoid accidental overwrites.`);
109
+ }
110
+
111
+ const resolvedSrc = path.resolve(projectRoot, src);
112
+
113
+ const globIgnore = exclude.flatMap(e => {
114
+ const stripped = e.replace(/\/$/, '');
115
+ return [stripped, `${stripped}/**`];
116
+ });
117
+
118
+ console.log(`📦 Packing [${name}] → ${resolvedZipPath}`);
119
+
120
+ const files = await glob('**', {
121
+ cwd: resolvedSrc,
122
+ dot: true,
123
+ nodir: true,
124
+ ignore: globIgnore,
125
+ });
126
+
127
+ const zipData = {};
128
+ for (const file of files) {
129
+ if (verbose) {
130
+ process.stdout.write(` ${file}\n`);
131
+ }
132
+ zipData[`${projectName}/${file}`] = readFileSync(path.join(resolvedSrc, file));
133
+ }
134
+
135
+ const zipped = zipSync(zipData);
136
+ writeFileSync(resolvedZipPath, zipped);
137
+ }
138
+
139
+
64
140
  /**
65
141
  * Rsync a single target
66
142
  *
@@ -143,11 +219,13 @@ function rsyncDryRun(args) {
143
219
 
144
220
  let stdout = '';
145
221
  let stderr = '';
222
+ let rejected = false;
146
223
 
147
224
  childProcess.stdout.on('data', (data) => { stdout += data.toString(); });
148
225
  childProcess.stderr.on('data', (data) => { stderr += data.toString(); });
149
226
 
150
227
  childProcess.on('close', (code) => {
228
+ if (rejected) return;
151
229
  if (code === 0) {
152
230
  const deletions = stdout
153
231
  .split('\n')
@@ -160,6 +238,7 @@ function rsyncDryRun(args) {
160
238
  });
161
239
 
162
240
  childProcess.on('error', (err) => {
241
+ rejected = true;
163
242
  if (err.code === 'ENOENT') {
164
243
  reject(new Error('rsync not found. Please ensure rsync is installed on your system.'));
165
244
  } else {
@@ -203,15 +282,46 @@ async function confirmDeletion(deletions, destination) {
203
282
  }
204
283
 
205
284
 
285
+ /**
286
+ * Prompt the user to choose a ship target from the available options.
287
+ * Accepts either the target name or its list number.
288
+ *
289
+ * @param {string[]} configuredTargetNames - Target names from haptiq.config.js
290
+ * @returns {Promise<string>} The chosen target name
291
+ */
292
+ async function promptTarget(configuredTargetNames) {
293
+ const options = [...configuredTargetNames, 'dist'];
294
+
295
+ console.log('\nNo target specified. Choose one of the available targets to ship to:\n');
296
+ options.forEach((name, i) => {
297
+ console.log(` ${i + 1}) ${name}`);
298
+ });
299
+
300
+ const answer = (await readLine('\nTarget: ')).trim();
301
+
302
+ const num = parseInt(answer, 10);
303
+ if (!isNaN(num) && num >= 1 && num <= options.length) {
304
+ return options[num - 1];
305
+ }
306
+
307
+ if (options.includes(answer)) {
308
+ return answer;
309
+ }
310
+
311
+ throw new Error(`"${answer}" is not a valid target. Run \`kit ship <target>\` to skip this prompt.`);
312
+ }
313
+
314
+
206
315
  /**
207
316
  * Read a single line from stdin.
208
317
  *
318
+ * @param {string} prompt - Text to display before waiting for input
209
319
  * @returns {Promise<string>}
210
320
  */
211
- function readLine() {
321
+ function readLine(prompt = '') {
212
322
  return new Promise((resolve) => {
213
323
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
214
- rl.question('', (answer) => {
324
+ rl.question(prompt, (answer) => {
215
325
  rl.close();
216
326
  resolve(answer);
217
327
  });
@@ -231,6 +341,7 @@ function spawnRsync(args, verbose) {
231
341
  const childProcess = spawn('rsync', args, { shell: false });
232
342
 
233
343
  let stderr = '';
344
+ let rejected = false;
234
345
 
235
346
  if (verbose) {
236
347
  childProcess.stdout.on('data', (data) => process.stdout.write(data));
@@ -241,6 +352,7 @@ function spawnRsync(args, verbose) {
241
352
  });
242
353
 
243
354
  childProcess.on('close', (code) => {
355
+ if (rejected) return;
244
356
  if (code === 0) {
245
357
  resolve();
246
358
  } else {
@@ -249,6 +361,7 @@ function spawnRsync(args, verbose) {
249
361
  });
250
362
 
251
363
  childProcess.on('error', (err) => {
364
+ rejected = true;
252
365
  if (err.code === 'ENOENT') {
253
366
  reject(new Error('rsync not found. Please ensure rsync is installed on your system.'));
254
367
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haptiq/kit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Build tools for Haptiq projects.",
5
5
  "keywords": [
6
6
  "build",
@@ -24,6 +24,7 @@
24
24
  "@haptiq/browserslist-config": "^0.1.0",
25
25
  "browserslist": "^4.28.2",
26
26
  "commander": "^15.0.0",
27
+ "fflate": "^0.8.0",
27
28
  "glob": "^13.0.6",
28
29
  "lightningcss": "^1.32.0",
29
30
  "sass": "^1.101.0",