This repository has been archived by the owner on Nov 21, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 34
/
Copy pathindex.ts
324 lines (312 loc) · 11.6 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
#!/usr/bin/env node
import process from "process";
import path from "path";
import os from "os";
import fs from "fs-extra";
import execa from "execa";
import archiver from "archiver";
import cryptoRandomString from "crypto-random-string";
import commander from "commander";
import globby from "globby";
import bash from "dedent";
export default async function caxa({
input,
output,
command,
force = true,
exclude = [],
filter = (() => {
const pathsToExclude = globby
.sync(exclude, {
expandDirectories: false,
onlyFiles: false,
})
.map((pathToExclude: string) => path.join(pathToExclude));
return (pathToCopy: string) =>
!pathsToExclude.includes(path.join(pathToCopy));
})(),
dedupe = true,
prepareCommand,
prepare = async (buildDirectory: string) => {
if (prepareCommand === undefined) return;
await execa.command(prepareCommand, { cwd: buildDirectory, shell: true });
},
includeNode = true,
stub = path.join(
__dirname,
`../stubs/stub--${process.platform}--${process.arch}`
),
identifier = path.join(
path.basename(path.basename(path.basename(output, ".app"), ".exe"), ".sh"),
cryptoRandomString({ length: 10, type: "alphanumeric" }).toLowerCase()
),
removeBuildDirectory = true,
uncompressionMessage,
}: {
input: string;
output: string;
command: string[];
force?: boolean;
exclude?: string[];
filter?: fs.CopyFilterSync | fs.CopyFilterAsync;
dedupe?: boolean;
prepareCommand?: string;
prepare?: (buildDirectory: string) => Promise<void>;
includeNode?: boolean;
stub?: string;
identifier?: string;
removeBuildDirectory?: boolean;
uncompressionMessage?: string;
}): Promise<void> {
if (!(await fs.pathExists(input)) || !(await fs.lstat(input)).isDirectory())
throw new Error(
`The path to your application isn’t a directory: ‘${input}’.`
);
if ((await fs.pathExists(output)) && !force)
throw new Error(`Output already exists: ‘${output}’.`);
if (process.platform === "win32" && !output.endsWith(".exe"))
throw new Error("An Windows executable must end in ‘.exe’.");
const buildDirectory = path.join(
os.tmpdir(),
"caxa/builds",
cryptoRandomString({ length: 10, type: "alphanumeric" }).toLowerCase()
);
await fs.copy(input, buildDirectory, { filter });
if (dedupe)
await execa("npm", ["dedupe", "--production"], { cwd: buildDirectory });
await prepare(buildDirectory);
if (includeNode) {
const node = path.join(
buildDirectory,
"node_modules/.bin",
path.basename(process.execPath)
);
await fs.ensureDir(path.dirname(node));
await fs.copyFile(process.execPath, node);
}
await fs.ensureDir(path.dirname(output));
await fs.remove(output);
if (output.endsWith(".app")) {
if (process.platform !== "darwin")
throw new Error(
"macOS Application Bundles (.app) are supported in macOS only."
);
await fs.ensureDir(path.join(output, "Contents/Resources"));
await fs.move(
buildDirectory,
path.join(output, "Contents/Resources/application")
);
await fs.ensureDir(path.join(output, "Contents/MacOS"));
const name = path.basename(output, ".app");
await fs.writeFile(
path.join(output, "Contents/MacOS", name),
`#!/usr/bin/env sh\nopen "$(dirname "$0")/../Resources/${name}"`,
{ mode: 0o755 }
);
await fs.writeFile(
path.join(output, "Contents/Resources", name),
`#!/usr/bin/env sh\n${command
.map(
(part) =>
`"${part.replace(
/\{\{\s*caxa\s*\}\}/g,
`$(dirname "$0")/application`
)}"`
)
.join(" ")}`,
{ mode: 0o755 }
);
} else if (output.endsWith(".sh")) {
if (process.platform === "win32")
throw new Error("The Shell Stub (.sh) isn’t supported in Windows.");
let stub =
bash`
#!/usr/bin/env sh
export CAXA_TEMPORARY_DIRECTORY="$(dirname $(mktemp))/caxa"
export CAXA_EXTRACTION_ATTEMPT=-1
while true
do
export CAXA_EXTRACTION_ATTEMPT=$(( CAXA_EXTRACTION_ATTEMPT + 1 ))
export CAXA_LOCK="$CAXA_TEMPORARY_DIRECTORY/locks/${identifier}/$CAXA_EXTRACTION_ATTEMPT"
export CAXA_APPLICATION_DIRECTORY="$CAXA_TEMPORARY_DIRECTORY/applications/${identifier}/$CAXA_EXTRACTION_ATTEMPT"
if [ -d "$CAXA_APPLICATION_DIRECTORY" ]
then
if [ -d "$CAXA_LOCK" ]
then
continue
else
break
fi
else
${
uncompressionMessage === undefined
? bash``
: bash`echo "${uncompressionMessage}" >&2`
}
mkdir -p "$CAXA_LOCK"
mkdir -p "$CAXA_APPLICATION_DIRECTORY"
tail -n+{{caxa-number-of-lines}} "$0" | tar -xz -C "$CAXA_APPLICATION_DIRECTORY"
rmdir "$CAXA_LOCK"
break
fi
done
exec ${command
.map(
(commandPart) =>
`"${commandPart.replace(
/\{\{caxa\}\}/g,
`"$CAXA_APPLICATION_DIRECTORY"`
)}"`
)
.join(" ")} "$@"
` + "\n";
stub = stub.replace(
"{{caxa-number-of-lines}}",
String(stub.split("\n").length)
);
await fs.writeFile(output, stub, { mode: 0o755 });
await appendTarballOfBuildDirectoryToOutput();
} else {
if (!(await fs.pathExists(stub)))
throw new Error(
`Stub not found (your operating system / architecture may be unsupported): ‘${stub}’`
);
await fs.copyFile(stub, output);
await fs.chmod(output, 0o755);
await appendTarballOfBuildDirectoryToOutput();
await fs.appendFile(
output,
"\n" + JSON.stringify({ identifier, command, uncompressionMessage })
);
}
if (removeBuildDirectory) await fs.remove(buildDirectory);
async function appendTarballOfBuildDirectoryToOutput(): Promise<void> {
const archive = archiver("tar", { gzip: true });
const archiveStream = fs.createWriteStream(output, { flags: "a" });
archive.pipe(archiveStream);
archive.directory(buildDirectory, false);
await archive.finalize();
// FIXME: Use ‘stream/promises’ when Node.js 16 lands, because then an LTS version will have the feature: await stream.finished(archiveStream);
await new Promise((resolve, reject) => {
archiveStream.on("finish", resolve);
archiveStream.on("error", reject);
});
}
}
if (require.main === module)
(async () => {
await commander.program
.version(require("../package.json").version)
.requiredOption("-i, --input <input>", "The input directory to package.")
.requiredOption(
"-o, --output <output>",
"The path where the executable will be produced. On Windows must end in ‘.exe’. In macOS may end in ‘.app’ to generate a macOS Application Bundle. In macOS and Linux, may end in ‘.sh’ to use the Shell Stub, which takes less space, but depends on some tools being installed on the end-user machine, for example, ‘tar’, ‘tail’, and so forth."
)
.option("-f, --force", "[Advanced] Overwrite output if it exists.", true)
.option("-F, --no-force")
.option(
"-e, --exclude <path...>",
`[Advanced] Paths to exclude from the build. The paths are passed to /~https://github.com/sindresorhus/globby and paths that match will be excluded. [Super-Advanced, Please don’t use] If you wish to emulate ‘--include’, you may use ‘--exclude "*" ".*" "!path-to-include" ...’. The problem with ‘--include’ is that if you change your project structure but forget to change the caxa invocation, then things will subtly fail only in the packaged version.`
)
.option(
"-d, --dedupe",
"[Advanced] Run ‘npm dedupe --production’ on the build directory.",
true
)
.option("-D, --no-dedupe")
.option(
"-p, --prepare-command <command>",
"[Advanced] Command to run on the build directory while packaging."
)
.option(
"-n, --include-node",
"[Advanced] Copy the Node.js executable to ‘{{caxa}}/node_modules/.bin/node’.",
true
)
.option("-N, --no-include-node")
.option("-s, --stub <path>", "[Advanced] Path to the stub.")
.option(
"--identifier <identifier>",
"[Advanced] Build identifier, which is the path in which the application will be unpacked."
)
.option(
"-b, --remove-build-directory",
"[Advanced] Remove the build directory after the build.",
true
)
.option("-B, --no-remove-build-directory")
.option(
"-m, --uncompression-message <message>",
"[Advanced] A message to show when uncompressing, for example, ‘This may take a while to run the first time, please wait...’."
)
.arguments("<command...>")
.description("Package Node.js applications into executable binaries.", {
command:
"The command to run and optional arguments to pass to the command every time the executable is called. Paths must be absolute. The ‘{{caxa}}’ placeholder is substituted for the folder from which the package runs. The ‘node’ executable is available at ‘{{caxa}}/node_modules/.bin/node’. Use double quotes to delimit the command and each argument.",
})
.addHelpText(
"after",
`
Examples:
Windows:
> caxa --input "examples/echo-command-line-parameters" --output "echo-command-line-parameters.exe" -- "{{caxa}}/node_modules/.bin/node" "{{caxa}}/index.js" "some" "embedded arguments" "--an-option-thats-part-of-the-command"
macOS/Linux:
$ caxa --input "examples/echo-command-line-parameters" --output "echo-command-line-parameters" -- "{{caxa}}/node_modules/.bin/node" "{{caxa}}/index.js" "some" "embedded arguments" "--an-option-thats-part-of-the-command"
macOS (Application Bundle):
$ caxa --input "examples/echo-command-line-parameters" --output "Echo Command Line Parameters.app" -- "{{caxa}}/node_modules/.bin/node" "{{caxa}}/index.js" "some" "embedded arguments" "--an-option-thats-part-of-the-command"
macOS/Linux (Shell Stub):
$ caxa --input "examples/echo-command-line-parameters" --output "echo-command-line-parameters.sh" -- "{{caxa}}/node_modules/.bin/node" "{{caxa}}/index.js" "some" "embedded arguments" "--an-option-thats-part-of-the-command"
`
)
.action(
async (
command: string[],
{
input,
output,
force,
exclude = [],
dedupe,
prepareCommand,
includeNode,
stub,
identifier,
removeBuildDirectory,
uncompressionMessage,
}: {
input: string;
output: string;
force?: boolean;
exclude?: string[];
dedupe?: boolean;
prepareCommand?: string;
includeNode?: boolean;
stub?: string;
identifier?: string;
removeBuildDirectory?: boolean;
uncompressionMessage?: string;
}
) => {
try {
await caxa({
input,
output,
command,
force,
exclude,
dedupe,
prepareCommand,
includeNode,
stub,
identifier,
removeBuildDirectory,
uncompressionMessage,
});
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
)
.parseAsync();
})();