@haptiq/kit 0.3.0 → 0.5.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/bin/haptiq.kit.js +39 -2
- package/lib/ship.js +262 -0
- package/package.json +1 -1
package/bin/haptiq.kit.js
CHANGED
|
@@ -21,6 +21,7 @@ import path from 'path';
|
|
|
21
21
|
import packageJson from '../package.json' with { type: 'json' };
|
|
22
22
|
import { buildCSS } from '../lib/css.js';
|
|
23
23
|
import { buildJS } from '../lib/js.js';
|
|
24
|
+
import { ship } from '../lib/ship.js';
|
|
24
25
|
|
|
25
26
|
const { version } = packageJson;
|
|
26
27
|
|
|
@@ -80,6 +81,31 @@ async function loadConfig(verbose = false) {
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// Validate ship config structure if present
|
|
85
|
+
if (config.ship) {
|
|
86
|
+
if (typeof config.ship !== 'object' || Array.isArray(config.ship)) {
|
|
87
|
+
throw new Error('ship configuration must be an object');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (config.ship.targets) {
|
|
91
|
+
if (typeof config.ship.targets !== 'object' || Array.isArray(config.ship.targets)) {
|
|
92
|
+
throw new Error('ship.targets must be an object');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [targetName, targetConfig] of Object.entries(config.ship.targets)) {
|
|
96
|
+
if (targetConfig.dest && typeof targetConfig.dest !== 'string') {
|
|
97
|
+
throw new Error(`ship.targets.${targetName}.dest must be a string`);
|
|
98
|
+
}
|
|
99
|
+
if (targetConfig.host && typeof targetConfig.host !== 'string') {
|
|
100
|
+
throw new Error(`ship.targets.${targetName}.host must be a string`);
|
|
101
|
+
}
|
|
102
|
+
if (targetConfig.dev !== undefined && typeof targetConfig.dev !== 'boolean') {
|
|
103
|
+
throw new Error(`ship.targets.${targetName}.dev must be a boolean (true or false, not a string)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
return config;
|
|
84
110
|
} catch (configError) {
|
|
85
111
|
// ERR_MODULE_NOT_FOUND is the ESM equivalent of MODULE_NOT_FOUND
|
|
@@ -107,10 +133,12 @@ async function loadConfig(verbose = false) {
|
|
|
107
133
|
* @returns {Function} Command handler function for commander.js
|
|
108
134
|
*/
|
|
109
135
|
function createCommandHandler(taskName, taskFunction) {
|
|
110
|
-
return async (
|
|
136
|
+
return async (...args) => {
|
|
137
|
+
const options = args[args.length - 1];
|
|
138
|
+
const positionalArgs = args.slice(0, -1);
|
|
111
139
|
try {
|
|
112
140
|
const config = await loadConfig(options.verbose);
|
|
113
|
-
await taskFunction(config, options);
|
|
141
|
+
await taskFunction(config, options, ...positionalArgs);
|
|
114
142
|
} catch (error) {
|
|
115
143
|
console.error(`❌ ${taskName} failed:`, error.message);
|
|
116
144
|
process.exit(1);
|
|
@@ -144,4 +172,13 @@ program
|
|
|
144
172
|
await buildJS(config, options.verbose, { only: options.only, skip: options.skip, dev: options.dev });
|
|
145
173
|
}));
|
|
146
174
|
|
|
175
|
+
program
|
|
176
|
+
.command('ship [target]')
|
|
177
|
+
.description('Build assets and ship to remote server via rsync')
|
|
178
|
+
.option('--dev', 'Build without minification before shipping')
|
|
179
|
+
.option('--verbose', 'Show detailed output')
|
|
180
|
+
.action(createCommandHandler('Ship', async (config, options, target) => {
|
|
181
|
+
await ship(config, options.verbose, { target, dev: options.dev });
|
|
182
|
+
}));
|
|
183
|
+
|
|
147
184
|
program.parse();
|
package/lib/ship.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ship Module
|
|
3
|
+
*
|
|
4
|
+
* Builds CSS and JS assets then syncs them to a remote or local
|
|
5
|
+
* destination via rsync. Target configuration lives in haptiq.config.js
|
|
6
|
+
* under the `ship` key.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
import readline from 'readline';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { buildCSS } from './css.js';
|
|
13
|
+
import { buildJS } from './js.js';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Main ship function — builds assets then rsyncs for each target
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} config - Configuration object from haptiq.config.js
|
|
20
|
+
* @param {boolean} verbose - Show detailed output
|
|
21
|
+
* @param {{ target?: string, dev?: boolean }} options - CLI options
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
async function ship(config, verbose, options = {}) {
|
|
25
|
+
const { target: targetName, dev: cliDev } = options;
|
|
26
|
+
|
|
27
|
+
const shipConfig = config.ship;
|
|
28
|
+
|
|
29
|
+
const { targets = {}, src = './', exclude, delete: deleteRemoved = false } = shipConfig ?? {};
|
|
30
|
+
|
|
31
|
+
if (!exclude || exclude.length === 0) {
|
|
32
|
+
console.warn('⚠️ No ship.exclude configured — everything in src will be synced including node_modules. Add an exclude list to haptiq.config.js to filter unwanted files.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const configuredTargetNames = Object.keys(targets);
|
|
36
|
+
let targetsToProcess;
|
|
37
|
+
|
|
38
|
+
if (targetName) {
|
|
39
|
+
if (targetName !== 'dist' && !targets[targetName]) {
|
|
40
|
+
const available = [...configuredTargetNames, 'dist'].join(', ');
|
|
41
|
+
throw new Error(`Target "${targetName}" not found. Available: ${available}`);
|
|
42
|
+
}
|
|
43
|
+
targetsToProcess = [targetName];
|
|
44
|
+
} else {
|
|
45
|
+
if (configuredTargetNames.length === 0) {
|
|
46
|
+
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
|
+
}
|
|
48
|
+
targetsToProcess = configuredTargetNames;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const name of targetsToProcess) {
|
|
52
|
+
const targetConfig = targets[name] ?? {};
|
|
53
|
+
const dev = cliDev || targetConfig.dev || false;
|
|
54
|
+
|
|
55
|
+
await buildCSS(config, verbose, dev);
|
|
56
|
+
await buildJS(config, verbose, { dev });
|
|
57
|
+
await shipSingleTarget(name, targetConfig, { src, exclude, deleteRemoved }, verbose);
|
|
58
|
+
|
|
59
|
+
console.log(`✅ Shipped to [${name}].`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Rsync a single target
|
|
66
|
+
*
|
|
67
|
+
* @param {string} name - Target name (for error messages)
|
|
68
|
+
* @param {{ dest?: string, host?: string, dev?: boolean }} targetConfig - Target settings. `dest` is required for remote targets; local targets derive it automatically.
|
|
69
|
+
* @param {{ src: string, exclude: string[], deleteRemoved: boolean }} shipConfig - Shared settings
|
|
70
|
+
* @param {boolean} verbose - Stream rsync output to console
|
|
71
|
+
* @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
async function shipSingleTarget(name, targetConfig, shipConfig, verbose) {
|
|
74
|
+
const { dest, host } = targetConfig;
|
|
75
|
+
const { src = './', exclude = [], deleteRemoved = false } = shipConfig;
|
|
76
|
+
|
|
77
|
+
let destination;
|
|
78
|
+
|
|
79
|
+
if (host) {
|
|
80
|
+
if (!dest) {
|
|
81
|
+
throw new Error(`ship.targets.${name}: missing required field "dest" for remote target`);
|
|
82
|
+
}
|
|
83
|
+
destination = `${host}:${dest}`;
|
|
84
|
+
} else {
|
|
85
|
+
if (dest) {
|
|
86
|
+
console.warn(`⚠️ ship.targets.${name}: "dest" has no effect on local targets — destination is always derived as ../project-name-dist/.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const projectRoot = process.cwd();
|
|
90
|
+
const projectName = path.basename(projectRoot);
|
|
91
|
+
const localDest = path.resolve(projectRoot, '..', `${projectName}-dist`);
|
|
92
|
+
|
|
93
|
+
if (existsSync(localDest)) {
|
|
94
|
+
throw new Error(`Local destination "${localDest}" already exists. Remove it manually before shipping to avoid overwriting existing content.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
destination = localDest;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const projectRoot = process.cwd();
|
|
101
|
+
const resolvedSrc = path.resolve(projectRoot, src);
|
|
102
|
+
if (resolvedSrc !== projectRoot && !resolvedSrc.startsWith(projectRoot + path.sep)) {
|
|
103
|
+
throw new Error(`ship.src must be inside the project root. Got: ${resolvedSrc}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const normalizedSrc = src.endsWith('/') ? src : `${src}/`;
|
|
107
|
+
|
|
108
|
+
const excludeArgs = exclude.flatMap(e => ['--exclude', e]);
|
|
109
|
+
const deleteArgs = deleteRemoved ? ['--delete', '--delete-excluded'] : [];
|
|
110
|
+
|
|
111
|
+
const args = [
|
|
112
|
+
'-az',
|
|
113
|
+
...(verbose ? ['-v'] : []),
|
|
114
|
+
...excludeArgs,
|
|
115
|
+
...deleteArgs,
|
|
116
|
+
normalizedSrc,
|
|
117
|
+
destination,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
console.log(`🚀 Shipping [${name}] → ${destination}`);
|
|
121
|
+
|
|
122
|
+
if (deleteRemoved) {
|
|
123
|
+
const dryRunArgs = ['-azv', '-n', ...excludeArgs, ...deleteArgs, normalizedSrc, destination];
|
|
124
|
+
const deletions = await rsyncDryRun(dryRunArgs);
|
|
125
|
+
if (deletions.length > 0) {
|
|
126
|
+
await confirmDeletion(deletions, destination);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await spawnRsync(args, verbose);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run rsync in dry-run mode and return the list of files that would be deleted.
|
|
136
|
+
*
|
|
137
|
+
* @param {string[]} args - rsync arguments (must include -n and -v)
|
|
138
|
+
* @returns {Promise<string[]>}
|
|
139
|
+
*/
|
|
140
|
+
function rsyncDryRun(args) {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const childProcess = spawn('rsync', args, { shell: false });
|
|
143
|
+
|
|
144
|
+
let stdout = '';
|
|
145
|
+
let stderr = '';
|
|
146
|
+
|
|
147
|
+
childProcess.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
148
|
+
childProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
149
|
+
|
|
150
|
+
childProcess.on('close', (code) => {
|
|
151
|
+
if (code === 0) {
|
|
152
|
+
const deletions = stdout
|
|
153
|
+
.split('\n')
|
|
154
|
+
.filter(line => line.startsWith('deleting '))
|
|
155
|
+
.map(line => line.slice('deleting '.length).trim());
|
|
156
|
+
resolve(deletions);
|
|
157
|
+
} else {
|
|
158
|
+
reject(new Error(`rsync dry-run failed: ${stderr.trim()}`));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
childProcess.on('error', (err) => {
|
|
163
|
+
if (err.code === 'ENOENT') {
|
|
164
|
+
reject(new Error('rsync not found. Please ensure rsync is installed on your system.'));
|
|
165
|
+
} else {
|
|
166
|
+
reject(new Error(`Failed to start rsync: ${err.message}`));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Show a deletion preview and require the user to type DELETE to continue.
|
|
175
|
+
*
|
|
176
|
+
* @param {string[]} deletions - Files that would be deleted
|
|
177
|
+
* @param {string} destination - Rsync destination (shown in the warning)
|
|
178
|
+
* @returns {Promise<void>}
|
|
179
|
+
*/
|
|
180
|
+
async function confirmDeletion(deletions, destination) {
|
|
181
|
+
const count = deletions.length;
|
|
182
|
+
const shown = deletions.slice(0, 7);
|
|
183
|
+
|
|
184
|
+
console.log(`\n⚠️ ${count} ${count === 1 ? 'file' : 'files'} will be permanently deleted from:`);
|
|
185
|
+
console.log(` ${destination}\n`);
|
|
186
|
+
|
|
187
|
+
for (const file of shown) {
|
|
188
|
+
console.log(` - ${file}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (count > 7) {
|
|
192
|
+
console.log(` … and ${count - 7} more files.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('\nWARNING: These files will be removed irrevocably!\n');
|
|
196
|
+
console.log('To proceed, type DELETE and press Enter (Ctrl+C to abort):');
|
|
197
|
+
|
|
198
|
+
const answer = await readLine();
|
|
199
|
+
|
|
200
|
+
if (answer !== 'DELETE') {
|
|
201
|
+
throw new Error('Aborted — sync cancelled.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read a single line from stdin.
|
|
208
|
+
*
|
|
209
|
+
* @returns {Promise<string>}
|
|
210
|
+
*/
|
|
211
|
+
function readLine() {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
214
|
+
rl.question('', (answer) => {
|
|
215
|
+
rl.close();
|
|
216
|
+
resolve(answer);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Spawn rsync as a child process without shell interpolation
|
|
224
|
+
*
|
|
225
|
+
* @param {string[]} args - rsync argument array
|
|
226
|
+
* @param {boolean} verbose - Pipe stdout to console
|
|
227
|
+
* @returns {Promise<void>}
|
|
228
|
+
*/
|
|
229
|
+
function spawnRsync(args, verbose) {
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
const childProcess = spawn('rsync', args, { shell: false });
|
|
232
|
+
|
|
233
|
+
let stderr = '';
|
|
234
|
+
|
|
235
|
+
if (verbose) {
|
|
236
|
+
childProcess.stdout.on('data', (data) => process.stdout.write(data));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
childProcess.stderr.on('data', (data) => {
|
|
240
|
+
stderr += data.toString();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
childProcess.on('close', (code) => {
|
|
244
|
+
if (code === 0) {
|
|
245
|
+
resolve();
|
|
246
|
+
} else {
|
|
247
|
+
reject(new Error(`rsync failed: ${stderr.trim()}`));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
childProcess.on('error', (err) => {
|
|
252
|
+
if (err.code === 'ENOENT') {
|
|
253
|
+
reject(new Error('rsync not found. Please ensure rsync is installed on your system.'));
|
|
254
|
+
} else {
|
|
255
|
+
reject(new Error(`Failed to start rsync: ${err.message}`));
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
export { ship };
|