harbor-templater 0.0.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 +394 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/dist/commands/init/index.d.ts +16 -0
- package/dist/commands/init/index.js +302 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/commands.d.ts +1 -0
- package/dist/lib/commands.js +13 -0
- package/dist/lib/environment.d.ts +3 -0
- package/dist/lib/environment.js +14 -0
- package/dist/lib/fs-ops.d.ts +6 -0
- package/dist/lib/fs-ops.js +32 -0
- package/dist/lib/merge.d.ts +5 -0
- package/dist/lib/merge.js +65 -0
- package/dist/lib/sources.d.ts +8 -0
- package/dist/lib/sources.js +77 -0
- package/dist/lib/template-engine.d.ts +12 -0
- package/dist/lib/template-engine.js +76 -0
- package/dist/lib/template-json.d.ts +71 -0
- package/dist/lib/template-json.js +1 -0
- package/oclif.manifest.json +94 -0
- package/package.json +95 -0
package/README.md
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# harbor-templater
|
|
2
|
+
|
|
3
|
+
A new CLI generated with oclif
|
|
4
|
+
|
|
5
|
+
## Dynamic Templates (WIP)
|
|
6
|
+
|
|
7
|
+
This CLI is being extended into a dynamic project scaffolder (Vite-style) driven by a JSON template.
|
|
8
|
+
|
|
9
|
+
- Template JSON spec: ./docs/template-json.md
|
|
10
|
+
- Examples: ./docs/examples/
|
|
11
|
+
|
|
12
|
+
[](https://oclif.io)
|
|
13
|
+
[](https://npmjs.org/package/harbor-templater)
|
|
14
|
+
[](https://npmjs.org/package/harbor-templater)
|
|
15
|
+
|
|
16
|
+
<!-- toc -->
|
|
17
|
+
* [harbor-templater](#harbor-templater)
|
|
18
|
+
* [Usage](#usage)
|
|
19
|
+
* [Commands](#commands)
|
|
20
|
+
<!-- tocstop -->
|
|
21
|
+
|
|
22
|
+
# Usage
|
|
23
|
+
|
|
24
|
+
<!-- usage -->
|
|
25
|
+
```sh-session
|
|
26
|
+
$ npm install -g harbor-templater
|
|
27
|
+
$ harbor-templater COMMAND
|
|
28
|
+
running command...
|
|
29
|
+
$ harbor-templater (--version)
|
|
30
|
+
harbor-templater/0.0.0 win32-x64 node-v24.1.0
|
|
31
|
+
$ harbor-templater --help [COMMAND]
|
|
32
|
+
USAGE
|
|
33
|
+
$ harbor-templater COMMAND
|
|
34
|
+
...
|
|
35
|
+
```
|
|
36
|
+
<!-- usagestop -->
|
|
37
|
+
|
|
38
|
+
# Commands
|
|
39
|
+
|
|
40
|
+
<!-- commands -->
|
|
41
|
+
* [`harbor-templater help [COMMAND]`](#harbor-templater-help-command)
|
|
42
|
+
* [`harbor-templater init`](#harbor-templater-init)
|
|
43
|
+
* [`harbor-templater plugins`](#harbor-templater-plugins)
|
|
44
|
+
* [`harbor-templater plugins add PLUGIN`](#harbor-templater-plugins-add-plugin)
|
|
45
|
+
* [`harbor-templater plugins:inspect PLUGIN...`](#harbor-templater-pluginsinspect-plugin)
|
|
46
|
+
* [`harbor-templater plugins install PLUGIN`](#harbor-templater-plugins-install-plugin)
|
|
47
|
+
* [`harbor-templater plugins link PATH`](#harbor-templater-plugins-link-path)
|
|
48
|
+
* [`harbor-templater plugins remove [PLUGIN]`](#harbor-templater-plugins-remove-plugin)
|
|
49
|
+
* [`harbor-templater plugins reset`](#harbor-templater-plugins-reset)
|
|
50
|
+
* [`harbor-templater plugins uninstall [PLUGIN]`](#harbor-templater-plugins-uninstall-plugin)
|
|
51
|
+
* [`harbor-templater plugins unlink [PLUGIN]`](#harbor-templater-plugins-unlink-plugin)
|
|
52
|
+
* [`harbor-templater plugins update`](#harbor-templater-plugins-update)
|
|
53
|
+
|
|
54
|
+
## `harbor-templater help [COMMAND]`
|
|
55
|
+
|
|
56
|
+
Display help for harbor-templater.
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
USAGE
|
|
60
|
+
$ harbor-templater help [COMMAND...] [-n]
|
|
61
|
+
|
|
62
|
+
ARGUMENTS
|
|
63
|
+
[COMMAND...] Command to show help for.
|
|
64
|
+
|
|
65
|
+
FLAGS
|
|
66
|
+
-n, --nested-commands Include all nested commands in the output.
|
|
67
|
+
|
|
68
|
+
DESCRIPTION
|
|
69
|
+
Display help for harbor-templater.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.36/src/commands/help.ts)_
|
|
73
|
+
|
|
74
|
+
## `harbor-templater init`
|
|
75
|
+
|
|
76
|
+
Scaffold a project from a JSON template
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
USAGE
|
|
80
|
+
$ harbor-templater init -t <value> [-o <value>] [--answer <value>...] [--defaults] [--dryRun]
|
|
81
|
+
[--conflict error|skip|overwrite|prompt] [--force] [--allowMissingEnv]
|
|
82
|
+
|
|
83
|
+
FLAGS
|
|
84
|
+
-o, --out=<value> [default: .] Base output directory (relative targets resolve from here)
|
|
85
|
+
-t, --template=<value> (required) Path or URL to a template JSON file
|
|
86
|
+
--allowMissingEnv Do not fail if an environment variable is missing for an environment step
|
|
87
|
+
--answer=<value>... Provide an answer: --answer key=value (repeatable)
|
|
88
|
+
--conflict=<option> [default: prompt] When a target already exists: error|skip|overwrite|prompt
|
|
89
|
+
<options: error|skip|overwrite|prompt>
|
|
90
|
+
--defaults Do not prompt; use defaults and provided --answer values
|
|
91
|
+
--dryRun Print actions without writing files or running commands
|
|
92
|
+
--force Overwrite existing files when copying
|
|
93
|
+
|
|
94
|
+
DESCRIPTION
|
|
95
|
+
Scaffold a project from a JSON template
|
|
96
|
+
|
|
97
|
+
EXAMPLES
|
|
98
|
+
$ harbor-templater init --template ./docs/examples/minimal.template.json --out ./my-app
|
|
99
|
+
|
|
100
|
+
$ harbor-templater init -t template.json -o . --answer projectDir=./my-app --defaults
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
_See code: [src/commands/init/index.ts](https://github.com/bendigiorgio/harbor-templater/blob/v0.0.0/src/commands/init/index.ts)_
|
|
104
|
+
|
|
105
|
+
## `harbor-templater plugins`
|
|
106
|
+
|
|
107
|
+
List installed plugins.
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
USAGE
|
|
111
|
+
$ harbor-templater plugins [--json] [--core]
|
|
112
|
+
|
|
113
|
+
FLAGS
|
|
114
|
+
--core Show core plugins.
|
|
115
|
+
|
|
116
|
+
GLOBAL FLAGS
|
|
117
|
+
--json Format output as json.
|
|
118
|
+
|
|
119
|
+
DESCRIPTION
|
|
120
|
+
List installed plugins.
|
|
121
|
+
|
|
122
|
+
EXAMPLES
|
|
123
|
+
$ harbor-templater plugins
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v5.4.54/src/commands/plugins/index.ts)_
|
|
127
|
+
|
|
128
|
+
## `harbor-templater plugins add PLUGIN`
|
|
129
|
+
|
|
130
|
+
Installs a plugin into harbor-templater.
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
USAGE
|
|
134
|
+
$ harbor-templater plugins add PLUGIN... [--json] [-f] [-h] [-s | -v]
|
|
135
|
+
|
|
136
|
+
ARGUMENTS
|
|
137
|
+
PLUGIN... Plugin to install.
|
|
138
|
+
|
|
139
|
+
FLAGS
|
|
140
|
+
-f, --force Force npm to fetch remote resources even if a local copy exists on disk.
|
|
141
|
+
-h, --help Show CLI help.
|
|
142
|
+
-s, --silent Silences npm output.
|
|
143
|
+
-v, --verbose Show verbose npm output.
|
|
144
|
+
|
|
145
|
+
GLOBAL FLAGS
|
|
146
|
+
--json Format output as json.
|
|
147
|
+
|
|
148
|
+
DESCRIPTION
|
|
149
|
+
Installs a plugin into harbor-templater.
|
|
150
|
+
|
|
151
|
+
Uses npm to install plugins.
|
|
152
|
+
|
|
153
|
+
Installation of a user-installed plugin will override a core plugin.
|
|
154
|
+
|
|
155
|
+
Use the HARBOR_TEMPLATER_NPM_LOG_LEVEL environment variable to set the npm loglevel.
|
|
156
|
+
Use the HARBOR_TEMPLATER_NPM_REGISTRY environment variable to set the npm registry.
|
|
157
|
+
|
|
158
|
+
ALIASES
|
|
159
|
+
$ harbor-templater plugins add
|
|
160
|
+
|
|
161
|
+
EXAMPLES
|
|
162
|
+
Install a plugin from npm registry.
|
|
163
|
+
|
|
164
|
+
$ harbor-templater plugins add myplugin
|
|
165
|
+
|
|
166
|
+
Install a plugin from a github url.
|
|
167
|
+
|
|
168
|
+
$ harbor-templater plugins add https://github.com/someuser/someplugin
|
|
169
|
+
|
|
170
|
+
Install a plugin from a github slug.
|
|
171
|
+
|
|
172
|
+
$ harbor-templater plugins add someuser/someplugin
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## `harbor-templater plugins:inspect PLUGIN...`
|
|
176
|
+
|
|
177
|
+
Displays installation properties of a plugin.
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
USAGE
|
|
181
|
+
$ harbor-templater plugins inspect PLUGIN...
|
|
182
|
+
|
|
183
|
+
ARGUMENTS
|
|
184
|
+
PLUGIN... [default: .] Plugin to inspect.
|
|
185
|
+
|
|
186
|
+
FLAGS
|
|
187
|
+
-h, --help Show CLI help.
|
|
188
|
+
-v, --verbose
|
|
189
|
+
|
|
190
|
+
GLOBAL FLAGS
|
|
191
|
+
--json Format output as json.
|
|
192
|
+
|
|
193
|
+
DESCRIPTION
|
|
194
|
+
Displays installation properties of a plugin.
|
|
195
|
+
|
|
196
|
+
EXAMPLES
|
|
197
|
+
$ harbor-templater plugins inspect myplugin
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v5.4.54/src/commands/plugins/inspect.ts)_
|
|
201
|
+
|
|
202
|
+
## `harbor-templater plugins install PLUGIN`
|
|
203
|
+
|
|
204
|
+
Installs a plugin into harbor-templater.
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
USAGE
|
|
208
|
+
$ harbor-templater plugins install PLUGIN... [--json] [-f] [-h] [-s | -v]
|
|
209
|
+
|
|
210
|
+
ARGUMENTS
|
|
211
|
+
PLUGIN... Plugin to install.
|
|
212
|
+
|
|
213
|
+
FLAGS
|
|
214
|
+
-f, --force Force npm to fetch remote resources even if a local copy exists on disk.
|
|
215
|
+
-h, --help Show CLI help.
|
|
216
|
+
-s, --silent Silences npm output.
|
|
217
|
+
-v, --verbose Show verbose npm output.
|
|
218
|
+
|
|
219
|
+
GLOBAL FLAGS
|
|
220
|
+
--json Format output as json.
|
|
221
|
+
|
|
222
|
+
DESCRIPTION
|
|
223
|
+
Installs a plugin into harbor-templater.
|
|
224
|
+
|
|
225
|
+
Uses npm to install plugins.
|
|
226
|
+
|
|
227
|
+
Installation of a user-installed plugin will override a core plugin.
|
|
228
|
+
|
|
229
|
+
Use the HARBOR_TEMPLATER_NPM_LOG_LEVEL environment variable to set the npm loglevel.
|
|
230
|
+
Use the HARBOR_TEMPLATER_NPM_REGISTRY environment variable to set the npm registry.
|
|
231
|
+
|
|
232
|
+
ALIASES
|
|
233
|
+
$ harbor-templater plugins add
|
|
234
|
+
|
|
235
|
+
EXAMPLES
|
|
236
|
+
Install a plugin from npm registry.
|
|
237
|
+
|
|
238
|
+
$ harbor-templater plugins install myplugin
|
|
239
|
+
|
|
240
|
+
Install a plugin from a github url.
|
|
241
|
+
|
|
242
|
+
$ harbor-templater plugins install https://github.com/someuser/someplugin
|
|
243
|
+
|
|
244
|
+
Install a plugin from a github slug.
|
|
245
|
+
|
|
246
|
+
$ harbor-templater plugins install someuser/someplugin
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v5.4.54/src/commands/plugins/install.ts)_
|
|
250
|
+
|
|
251
|
+
## `harbor-templater plugins link PATH`
|
|
252
|
+
|
|
253
|
+
Links a plugin into the CLI for development.
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
USAGE
|
|
257
|
+
$ harbor-templater plugins link PATH [-h] [--install] [-v]
|
|
258
|
+
|
|
259
|
+
ARGUMENTS
|
|
260
|
+
PATH [default: .] path to plugin
|
|
261
|
+
|
|
262
|
+
FLAGS
|
|
263
|
+
-h, --help Show CLI help.
|
|
264
|
+
-v, --verbose
|
|
265
|
+
--[no-]install Install dependencies after linking the plugin.
|
|
266
|
+
|
|
267
|
+
DESCRIPTION
|
|
268
|
+
Links a plugin into the CLI for development.
|
|
269
|
+
|
|
270
|
+
Installation of a linked plugin will override a user-installed or core plugin.
|
|
271
|
+
|
|
272
|
+
e.g. If you have a user-installed or core plugin that has a 'hello' command, installing a linked plugin with a 'hello'
|
|
273
|
+
command will override the user-installed or core plugin implementation. This is useful for development work.
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
EXAMPLES
|
|
277
|
+
$ harbor-templater plugins link myplugin
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v5.4.54/src/commands/plugins/link.ts)_
|
|
281
|
+
|
|
282
|
+
## `harbor-templater plugins remove [PLUGIN]`
|
|
283
|
+
|
|
284
|
+
Removes a plugin from the CLI.
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
USAGE
|
|
288
|
+
$ harbor-templater plugins remove [PLUGIN...] [-h] [-v]
|
|
289
|
+
|
|
290
|
+
ARGUMENTS
|
|
291
|
+
[PLUGIN...] plugin to uninstall
|
|
292
|
+
|
|
293
|
+
FLAGS
|
|
294
|
+
-h, --help Show CLI help.
|
|
295
|
+
-v, --verbose
|
|
296
|
+
|
|
297
|
+
DESCRIPTION
|
|
298
|
+
Removes a plugin from the CLI.
|
|
299
|
+
|
|
300
|
+
ALIASES
|
|
301
|
+
$ harbor-templater plugins unlink
|
|
302
|
+
$ harbor-templater plugins remove
|
|
303
|
+
|
|
304
|
+
EXAMPLES
|
|
305
|
+
$ harbor-templater plugins remove myplugin
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## `harbor-templater plugins reset`
|
|
309
|
+
|
|
310
|
+
Remove all user-installed and linked plugins.
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
USAGE
|
|
314
|
+
$ harbor-templater plugins reset [--hard] [--reinstall]
|
|
315
|
+
|
|
316
|
+
FLAGS
|
|
317
|
+
--hard Delete node_modules and package manager related files in addition to uninstalling plugins.
|
|
318
|
+
--reinstall Reinstall all plugins after uninstalling.
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v5.4.54/src/commands/plugins/reset.ts)_
|
|
322
|
+
|
|
323
|
+
## `harbor-templater plugins uninstall [PLUGIN]`
|
|
324
|
+
|
|
325
|
+
Removes a plugin from the CLI.
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
USAGE
|
|
329
|
+
$ harbor-templater plugins uninstall [PLUGIN...] [-h] [-v]
|
|
330
|
+
|
|
331
|
+
ARGUMENTS
|
|
332
|
+
[PLUGIN...] plugin to uninstall
|
|
333
|
+
|
|
334
|
+
FLAGS
|
|
335
|
+
-h, --help Show CLI help.
|
|
336
|
+
-v, --verbose
|
|
337
|
+
|
|
338
|
+
DESCRIPTION
|
|
339
|
+
Removes a plugin from the CLI.
|
|
340
|
+
|
|
341
|
+
ALIASES
|
|
342
|
+
$ harbor-templater plugins unlink
|
|
343
|
+
$ harbor-templater plugins remove
|
|
344
|
+
|
|
345
|
+
EXAMPLES
|
|
346
|
+
$ harbor-templater plugins uninstall myplugin
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v5.4.54/src/commands/plugins/uninstall.ts)_
|
|
350
|
+
|
|
351
|
+
## `harbor-templater plugins unlink [PLUGIN]`
|
|
352
|
+
|
|
353
|
+
Removes a plugin from the CLI.
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
USAGE
|
|
357
|
+
$ harbor-templater plugins unlink [PLUGIN...] [-h] [-v]
|
|
358
|
+
|
|
359
|
+
ARGUMENTS
|
|
360
|
+
[PLUGIN...] plugin to uninstall
|
|
361
|
+
|
|
362
|
+
FLAGS
|
|
363
|
+
-h, --help Show CLI help.
|
|
364
|
+
-v, --verbose
|
|
365
|
+
|
|
366
|
+
DESCRIPTION
|
|
367
|
+
Removes a plugin from the CLI.
|
|
368
|
+
|
|
369
|
+
ALIASES
|
|
370
|
+
$ harbor-templater plugins unlink
|
|
371
|
+
$ harbor-templater plugins remove
|
|
372
|
+
|
|
373
|
+
EXAMPLES
|
|
374
|
+
$ harbor-templater plugins unlink myplugin
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## `harbor-templater plugins update`
|
|
378
|
+
|
|
379
|
+
Update installed plugins.
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
USAGE
|
|
383
|
+
$ harbor-templater plugins update [-h] [-v]
|
|
384
|
+
|
|
385
|
+
FLAGS
|
|
386
|
+
-h, --help Show CLI help.
|
|
387
|
+
-v, --verbose
|
|
388
|
+
|
|
389
|
+
DESCRIPTION
|
|
390
|
+
Update installed plugins.
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
_See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/v5.4.54/src/commands/plugins/update.ts)_
|
|
394
|
+
<!-- commandsstop -->
|
package/bin/dev.cmd
ADDED
package/bin/dev.js
ADDED
package/bin/run.cmd
ADDED
package/bin/run.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command } from "@oclif/core";
|
|
2
|
+
export default class Init extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
template: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
out: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
answer: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
defaults: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
dryRun: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
conflict: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
allowMissingEnv: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
};
|
|
15
|
+
run(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
4
|
+
import { Command, Flags } from "@oclif/core";
|
|
5
|
+
import picomatch from "picomatch";
|
|
6
|
+
import { runShellCommand } from "../../lib/commands.js";
|
|
7
|
+
import { applyEnvironmentReplacements } from "../../lib/environment.js";
|
|
8
|
+
import { copyPath, ensureDir, looksLikeDirTarget, pathExists, } from "../../lib/fs-ops.js";
|
|
9
|
+
import { mergeIntoTarget } from "../../lib/merge.js";
|
|
10
|
+
import { resolveSource } from "../../lib/sources.js";
|
|
11
|
+
import { buildInitialContext, evaluateCondition, interpolate, resolveTargetPath, templateQuestions, templateSteps, } from "../../lib/template-engine.js";
|
|
12
|
+
export default class Init extends Command {
|
|
13
|
+
static description = "Scaffold a project from a JSON template";
|
|
14
|
+
static examples = [
|
|
15
|
+
`<%= config.bin %> <%= command.id %> --template ./docs/examples/minimal.template.json --out ./my-app`,
|
|
16
|
+
`<%= config.bin %> <%= command.id %> -t template.json -o . --answer projectDir=./my-app --defaults`,
|
|
17
|
+
];
|
|
18
|
+
static flags = {
|
|
19
|
+
template: Flags.string({
|
|
20
|
+
char: "t",
|
|
21
|
+
description: "Path or URL to a template JSON file",
|
|
22
|
+
required: true,
|
|
23
|
+
}),
|
|
24
|
+
out: Flags.string({
|
|
25
|
+
char: "o",
|
|
26
|
+
description: "Base output directory (relative targets resolve from here)",
|
|
27
|
+
default: ".",
|
|
28
|
+
}),
|
|
29
|
+
answer: Flags.string({
|
|
30
|
+
description: "Provide an answer: --answer key=value (repeatable)",
|
|
31
|
+
multiple: true,
|
|
32
|
+
}),
|
|
33
|
+
defaults: Flags.boolean({
|
|
34
|
+
description: "Do not prompt; use defaults and provided --answer values",
|
|
35
|
+
default: false,
|
|
36
|
+
}),
|
|
37
|
+
dryRun: Flags.boolean({
|
|
38
|
+
description: "Print actions without writing files or running commands",
|
|
39
|
+
default: false,
|
|
40
|
+
}),
|
|
41
|
+
conflict: Flags.string({
|
|
42
|
+
description: "When a target already exists: error|skip|overwrite|prompt",
|
|
43
|
+
options: ["error", "skip", "overwrite", "prompt"],
|
|
44
|
+
default: "prompt",
|
|
45
|
+
}),
|
|
46
|
+
force: Flags.boolean({
|
|
47
|
+
description: "Overwrite existing files when copying",
|
|
48
|
+
default: false,
|
|
49
|
+
}),
|
|
50
|
+
allowMissingEnv: Flags.boolean({
|
|
51
|
+
description: "Do not fail if an environment variable is missing for an environment step",
|
|
52
|
+
default: false,
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
async run() {
|
|
56
|
+
const { flags } = await this.parse(Init);
|
|
57
|
+
const outDir = path.resolve(flags.out);
|
|
58
|
+
await ensureDir(outDir);
|
|
59
|
+
const initialAnswers = parseAnswers(flags.answer ?? []);
|
|
60
|
+
const template = await loadTemplate(flags.template);
|
|
61
|
+
const ctx = buildInitialContext(outDir, initialAnswers);
|
|
62
|
+
await collectAnswers({
|
|
63
|
+
questions: templateQuestions(template),
|
|
64
|
+
ctx,
|
|
65
|
+
defaults: flags.defaults,
|
|
66
|
+
});
|
|
67
|
+
const steps = templateSteps(template);
|
|
68
|
+
await executeSteps({
|
|
69
|
+
steps,
|
|
70
|
+
ctx,
|
|
71
|
+
log: (m) => this.log(m),
|
|
72
|
+
dryRun: flags.dryRun,
|
|
73
|
+
force: flags.force,
|
|
74
|
+
conflict: flags.conflict,
|
|
75
|
+
defaults: flags.defaults,
|
|
76
|
+
allowMissingEnv: flags.allowMissingEnv,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function parseAnswers(pairs) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const pair of pairs) {
|
|
83
|
+
const idx = pair.indexOf("=");
|
|
84
|
+
if (idx === -1)
|
|
85
|
+
throw new Error(`Invalid --answer: ${pair} (expected key=value)`);
|
|
86
|
+
const key = pair.slice(0, idx).trim();
|
|
87
|
+
const value = pair.slice(idx + 1).trim();
|
|
88
|
+
out[key] = value;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
async function loadTemplate(templateRef) {
|
|
93
|
+
if (templateRef.startsWith("http://") || templateRef.startsWith("https://")) {
|
|
94
|
+
const response = await fetch(templateRef);
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`Failed to download template: ${response.status} ${response.statusText}`);
|
|
97
|
+
}
|
|
98
|
+
return (await response.json());
|
|
99
|
+
}
|
|
100
|
+
const raw = await fs.readFile(path.resolve(templateRef), "utf8");
|
|
101
|
+
return JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
async function collectAnswers(args) {
|
|
104
|
+
for (const q of args.questions) {
|
|
105
|
+
if (!evaluateCondition(args.ctx, q.when))
|
|
106
|
+
continue;
|
|
107
|
+
if (q.id in args.ctx.answers)
|
|
108
|
+
continue;
|
|
109
|
+
if (args.defaults) {
|
|
110
|
+
if (q.default !== undefined) {
|
|
111
|
+
args.ctx.answers[q.id] = q.default;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (q.required)
|
|
115
|
+
throw new Error(`Missing required answer: ${q.id}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
args.ctx.answers[q.id] = await promptForQuestion(q);
|
|
119
|
+
if (q.required &&
|
|
120
|
+
(args.ctx.answers[q.id] === "" || args.ctx.answers[q.id] == null)) {
|
|
121
|
+
throw new Error(`Missing required answer: ${q.id}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function promptForQuestion(q) {
|
|
126
|
+
switch (q.type) {
|
|
127
|
+
case "input":
|
|
128
|
+
return await input({
|
|
129
|
+
message: q.message,
|
|
130
|
+
default: typeof q.default === "string" ? q.default : undefined,
|
|
131
|
+
});
|
|
132
|
+
case "confirm":
|
|
133
|
+
return await confirm({
|
|
134
|
+
message: q.message,
|
|
135
|
+
default: typeof q.default === "boolean" ? q.default : undefined,
|
|
136
|
+
});
|
|
137
|
+
case "select": {
|
|
138
|
+
const choices = (q.options ?? []).map((o) => ({
|
|
139
|
+
name: o.label,
|
|
140
|
+
value: o.value,
|
|
141
|
+
}));
|
|
142
|
+
return await select({
|
|
143
|
+
message: q.message,
|
|
144
|
+
choices,
|
|
145
|
+
default: typeof q.default === "string" ? q.default : undefined,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
case "multiselect": {
|
|
149
|
+
const defaultValues = new Set(Array.isArray(q.default) ? q.default : []);
|
|
150
|
+
const choices = (q.options ?? []).map((o) => ({
|
|
151
|
+
name: o.label,
|
|
152
|
+
value: o.value,
|
|
153
|
+
checked: defaultValues.has(o.value),
|
|
154
|
+
}));
|
|
155
|
+
return await checkbox({ message: q.message, choices });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function executeSteps(args) {
|
|
160
|
+
for (const step of args.steps) {
|
|
161
|
+
if (!evaluateCondition(args.ctx, step.when))
|
|
162
|
+
continue;
|
|
163
|
+
switch (step.type) {
|
|
164
|
+
case "copy": {
|
|
165
|
+
const target = resolveTargetPath(args.ctx.outDir, interpolate(step.target, args.ctx));
|
|
166
|
+
const src = await resolveSource(interpolate(step.source, args.ctx));
|
|
167
|
+
if (args.dryRun) {
|
|
168
|
+
args.log(`copy ${step.source} -> ${target}`);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
const policy = effectiveConflictPolicy(args);
|
|
172
|
+
if (src.kind === "dir") {
|
|
173
|
+
const shouldProceed = await handleDirConflict({
|
|
174
|
+
policy,
|
|
175
|
+
source: step.source,
|
|
176
|
+
target,
|
|
177
|
+
defaults: args.defaults,
|
|
178
|
+
});
|
|
179
|
+
if (!shouldProceed) {
|
|
180
|
+
args.log(`skip copy ${step.source} -> ${target} (exists)`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
const excludeMatcher = step.exclude?.length
|
|
184
|
+
? picomatch(step.exclude, { dot: true })
|
|
185
|
+
: null;
|
|
186
|
+
await fs.cp(src.path, target, {
|
|
187
|
+
recursive: true,
|
|
188
|
+
force: policy === "overwrite" || args.force,
|
|
189
|
+
filter: excludeMatcher
|
|
190
|
+
? (srcEntry) => {
|
|
191
|
+
const rel = path
|
|
192
|
+
.relative(src.path, srcEntry)
|
|
193
|
+
.replaceAll("\\", "/");
|
|
194
|
+
// keep root
|
|
195
|
+
if (!rel)
|
|
196
|
+
return true;
|
|
197
|
+
return !excludeMatcher(rel);
|
|
198
|
+
}
|
|
199
|
+
: undefined,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// If target ends with '/', treat it as directory and keep filename
|
|
204
|
+
const finalTarget = looksLikeDirTarget(step.target)
|
|
205
|
+
? path.join(target, path.basename(src.path))
|
|
206
|
+
: target;
|
|
207
|
+
const shouldProceed = await handleFileConflict({
|
|
208
|
+
policy,
|
|
209
|
+
source: step.source,
|
|
210
|
+
target: finalTarget,
|
|
211
|
+
defaults: args.defaults,
|
|
212
|
+
});
|
|
213
|
+
if (!shouldProceed) {
|
|
214
|
+
args.log(`skip copy ${step.source} -> ${finalTarget} (exists)`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
await copyPath(src.path, finalTarget, {
|
|
218
|
+
force: policy === "overwrite" || args.force,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "merge": {
|
|
224
|
+
const target = resolveTargetPath(args.ctx.outDir, interpolate(step.target, args.ctx));
|
|
225
|
+
const src = await resolveSource(interpolate(step.source, args.ctx));
|
|
226
|
+
if (src.kind !== "file")
|
|
227
|
+
throw new Error("merge source must resolve to a file");
|
|
228
|
+
if (args.dryRun) {
|
|
229
|
+
args.log(`merge ${step.source} -> ${target}`);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
await mergeIntoTarget(src.path, target, step.merge ?? {});
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "environment": {
|
|
236
|
+
const target = resolveTargetPath(args.ctx.outDir, interpolate(step.target, args.ctx));
|
|
237
|
+
if (args.dryRun) {
|
|
238
|
+
args.log(`environment ${target}`);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
await applyEnvironmentReplacements(target, step.variables, {
|
|
242
|
+
allowMissing: args.allowMissingEnv,
|
|
243
|
+
});
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case "command": {
|
|
247
|
+
const cwd = resolveTargetPath(args.ctx.outDir, step.workingDirectory
|
|
248
|
+
? interpolate(step.workingDirectory, args.ctx)
|
|
249
|
+
: args.ctx.outDir);
|
|
250
|
+
const cmd = interpolate(step.command, args.ctx);
|
|
251
|
+
if (args.dryRun) {
|
|
252
|
+
args.log(`command (${cwd}): ${cmd}`);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
await runShellCommand(cmd, cwd);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function effectiveConflictPolicy(args) {
|
|
262
|
+
// If user asked for no prompting, don't prompt on conflicts.
|
|
263
|
+
if (args.defaults && args.conflict === "prompt")
|
|
264
|
+
return "error";
|
|
265
|
+
return args.conflict;
|
|
266
|
+
}
|
|
267
|
+
async function handleFileConflict(args) {
|
|
268
|
+
const exists = await pathExists(args.target);
|
|
269
|
+
if (!exists)
|
|
270
|
+
return true;
|
|
271
|
+
switch (args.policy) {
|
|
272
|
+
case "overwrite":
|
|
273
|
+
return true;
|
|
274
|
+
case "skip":
|
|
275
|
+
return false;
|
|
276
|
+
case "error":
|
|
277
|
+
throw new Error(`Target exists: ${args.target}`);
|
|
278
|
+
case "prompt":
|
|
279
|
+
return await confirm({
|
|
280
|
+
message: `Overwrite ${args.target}?`,
|
|
281
|
+
default: false,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function handleDirConflict(args) {
|
|
286
|
+
const exists = await pathExists(args.target);
|
|
287
|
+
if (!exists)
|
|
288
|
+
return true;
|
|
289
|
+
switch (args.policy) {
|
|
290
|
+
case "overwrite":
|
|
291
|
+
return true;
|
|
292
|
+
case "skip":
|
|
293
|
+
return false;
|
|
294
|
+
case "error":
|
|
295
|
+
throw new Error(`Target exists: ${args.target}`);
|
|
296
|
+
case "prompt":
|
|
297
|
+
return await confirm({
|
|
298
|
+
message: `Directory exists. Merge/overwrite into ${args.target}?`,
|
|
299
|
+
default: false,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runShellCommand(command: string, cwd: string): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export async function runShellCommand(command, cwd) {
|
|
3
|
+
await new Promise((resolve, reject) => {
|
|
4
|
+
const child = spawn(command, { cwd, shell: true, stdio: "inherit" });
|
|
5
|
+
child.on("error", reject);
|
|
6
|
+
child.on("exit", (code) => {
|
|
7
|
+
if (code === 0)
|
|
8
|
+
resolve();
|
|
9
|
+
else
|
|
10
|
+
reject(new Error(`Command failed (${code}): ${command}`));
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
export async function applyEnvironmentReplacements(targetPath, variables, options) {
|
|
3
|
+
let contents = await fs.readFile(targetPath, "utf8");
|
|
4
|
+
for (const [envName, placeholder] of Object.entries(variables)) {
|
|
5
|
+
const value = process.env[envName];
|
|
6
|
+
if (value == null) {
|
|
7
|
+
if (options.allowMissing)
|
|
8
|
+
continue;
|
|
9
|
+
throw new Error(`Missing environment variable: ${envName}`);
|
|
10
|
+
}
|
|
11
|
+
contents = contents.split(placeholder).join(value);
|
|
12
|
+
}
|
|
13
|
+
await fs.writeFile(targetPath, contents, "utf8");
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function ensureDir(dirPath: string): Promise<void>;
|
|
2
|
+
export declare function pathExists(targetPath: string): Promise<boolean>;
|
|
3
|
+
export declare function copyPath(sourcePath: string, targetPath: string, options: {
|
|
4
|
+
force: boolean;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
export declare function looksLikeDirTarget(target: string): boolean;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function ensureDir(dirPath) {
|
|
4
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
5
|
+
}
|
|
6
|
+
export async function pathExists(targetPath) {
|
|
7
|
+
return await fs
|
|
8
|
+
.access(targetPath)
|
|
9
|
+
.then(() => true)
|
|
10
|
+
.catch(() => false);
|
|
11
|
+
}
|
|
12
|
+
export async function copyPath(sourcePath, targetPath, options) {
|
|
13
|
+
const stat = await fs.stat(sourcePath);
|
|
14
|
+
if (stat.isDirectory()) {
|
|
15
|
+
await ensureDir(targetPath);
|
|
16
|
+
// Node 18+ supports fs.cp
|
|
17
|
+
await fs.cp(sourcePath, targetPath, {
|
|
18
|
+
recursive: true,
|
|
19
|
+
force: options.force,
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await ensureDir(path.dirname(targetPath));
|
|
24
|
+
if (!options.force) {
|
|
25
|
+
if (await pathExists(targetPath))
|
|
26
|
+
throw new Error(`Target exists: ${targetPath}`);
|
|
27
|
+
}
|
|
28
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
29
|
+
}
|
|
30
|
+
export function looksLikeDirTarget(target) {
|
|
31
|
+
return target.endsWith("/") || target.endsWith("\\");
|
|
32
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureDir } from "./fs-ops.js";
|
|
4
|
+
export async function mergeIntoTarget(sourcePath, targetPath, options) {
|
|
5
|
+
const format = options.format ?? "json";
|
|
6
|
+
const strategy = options.strategy ?? "deep";
|
|
7
|
+
if (format === "yaml") {
|
|
8
|
+
throw new Error("YAML merge is not implemented yet");
|
|
9
|
+
}
|
|
10
|
+
await ensureDir(path.dirname(targetPath));
|
|
11
|
+
if (format === "text") {
|
|
12
|
+
const incoming = await fs.readFile(sourcePath, "utf8");
|
|
13
|
+
let existing = "";
|
|
14
|
+
try {
|
|
15
|
+
existing = await fs.readFile(targetPath, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// doesn't exist
|
|
19
|
+
}
|
|
20
|
+
const merged = strategy === "prepend"
|
|
21
|
+
? `${incoming}${existing}`
|
|
22
|
+
: strategy === "append"
|
|
23
|
+
? `${existing}${incoming}`
|
|
24
|
+
: incoming;
|
|
25
|
+
await fs.writeFile(targetPath, merged, "utf8");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// json
|
|
29
|
+
const incoming = JSON.parse(await fs.readFile(sourcePath, "utf8"));
|
|
30
|
+
let existing = {};
|
|
31
|
+
try {
|
|
32
|
+
existing = JSON.parse(await fs.readFile(targetPath, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
existing = {};
|
|
36
|
+
}
|
|
37
|
+
const merged = strategy === "shallow"
|
|
38
|
+
? shallowMerge(existing, incoming)
|
|
39
|
+
: deepMerge(existing, incoming);
|
|
40
|
+
await fs.writeFile(targetPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
41
|
+
}
|
|
42
|
+
function shallowMerge(a, b) {
|
|
43
|
+
if (!isPlainObject(a) || !isPlainObject(b))
|
|
44
|
+
return b;
|
|
45
|
+
return { ...a, ...b };
|
|
46
|
+
}
|
|
47
|
+
function deepMerge(a, b) {
|
|
48
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
49
|
+
return [...a, ...b];
|
|
50
|
+
if (!isPlainObject(a) || !isPlainObject(b))
|
|
51
|
+
return b;
|
|
52
|
+
const out = { ...a };
|
|
53
|
+
for (const [key, value] of Object.entries(b)) {
|
|
54
|
+
if (Object.hasOwn(out, key)) {
|
|
55
|
+
out[key] = deepMerge(out[key], value);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
out[key] = value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function isPlainObject(value) {
|
|
64
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { x as untar } from "tar";
|
|
5
|
+
export async function resolveSource(source) {
|
|
6
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
7
|
+
return await downloadUrlToTempFile(source);
|
|
8
|
+
}
|
|
9
|
+
if (source.startsWith("github:")) {
|
|
10
|
+
return await downloadGitHubToTemp(source);
|
|
11
|
+
}
|
|
12
|
+
// local filesystem
|
|
13
|
+
const localPath = path.resolve(source);
|
|
14
|
+
const stat = await fs.stat(localPath);
|
|
15
|
+
return stat.isDirectory()
|
|
16
|
+
? { kind: "dir", path: localPath }
|
|
17
|
+
: { kind: "file", path: localPath };
|
|
18
|
+
}
|
|
19
|
+
async function downloadUrlToTempFile(url) {
|
|
20
|
+
const response = await fetch(url);
|
|
21
|
+
if (!response.ok)
|
|
22
|
+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
23
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
24
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "harbor-templater-url-"));
|
|
25
|
+
const filePath = path.join(tmpDir, "download");
|
|
26
|
+
await fs.writeFile(filePath, buffer);
|
|
27
|
+
return { kind: "file", path: filePath };
|
|
28
|
+
}
|
|
29
|
+
// github:<owner>/<repo>#<ref>:<path>
|
|
30
|
+
// If <path> points to a directory, returns kind=dir.
|
|
31
|
+
async function downloadGitHubToTemp(source) {
|
|
32
|
+
const parsed = parseGitHubSource(source);
|
|
33
|
+
const tarballUrl = `https://codeload.github.com/${parsed.owner}/${parsed.repo}/tar.gz/${parsed.ref}`;
|
|
34
|
+
const response = await fetch(tarballUrl);
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`Failed to download ${tarballUrl}: ${response.status} ${response.statusText}`);
|
|
37
|
+
}
|
|
38
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "harbor-templater-gh-"));
|
|
39
|
+
const tarPath = path.join(tmpDir, "repo.tgz");
|
|
40
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
41
|
+
await fs.writeFile(tarPath, buffer);
|
|
42
|
+
// GitHub tarballs wrap content in a single top-level folder: <repo>-<shaOrRef>/
|
|
43
|
+
const extractDir = path.join(tmpDir, "extract");
|
|
44
|
+
await fs.mkdir(extractDir);
|
|
45
|
+
await untar({ file: tarPath, cwd: extractDir });
|
|
46
|
+
const entries = await fs.readdir(extractDir);
|
|
47
|
+
if (entries.length === 0)
|
|
48
|
+
throw new Error("Downloaded GitHub tarball was empty");
|
|
49
|
+
const firstEntry = entries[0];
|
|
50
|
+
if (!firstEntry)
|
|
51
|
+
throw new Error("Downloaded GitHub tarball was empty");
|
|
52
|
+
const root = path.join(extractDir, firstEntry);
|
|
53
|
+
const candidate = path.join(root, parsed.subpath);
|
|
54
|
+
const stat = await fs.stat(candidate);
|
|
55
|
+
return stat.isDirectory()
|
|
56
|
+
? { kind: "dir", path: candidate }
|
|
57
|
+
: { kind: "file", path: candidate };
|
|
58
|
+
}
|
|
59
|
+
function parseGitHubSource(input) {
|
|
60
|
+
const trimmed = input.slice("github:".length);
|
|
61
|
+
const hashIdx = trimmed.indexOf("#");
|
|
62
|
+
const colonIdx = trimmed.indexOf(":");
|
|
63
|
+
if (hashIdx === -1 || colonIdx === -1 || colonIdx < hashIdx) {
|
|
64
|
+
throw new Error(`Invalid github source: ${input}. Expected github:<owner>/<repo>#<ref>:<path>`);
|
|
65
|
+
}
|
|
66
|
+
const repoPart = trimmed.slice(0, hashIdx);
|
|
67
|
+
const refPart = trimmed.slice(hashIdx + 1, colonIdx);
|
|
68
|
+
const pathPart = trimmed.slice(colonIdx + 1);
|
|
69
|
+
const [owner, repo] = repoPart.split("/");
|
|
70
|
+
if (!owner || !repo)
|
|
71
|
+
throw new Error(`Invalid github source: ${input} (missing owner/repo)`);
|
|
72
|
+
if (!refPart)
|
|
73
|
+
throw new Error(`Invalid github source: ${input} (missing ref)`);
|
|
74
|
+
if (!pathPart)
|
|
75
|
+
throw new Error(`Invalid github source: ${input} (missing path)`);
|
|
76
|
+
return { owner, repo, ref: refPart, subpath: pathPart };
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Condition, JSONTemplate, TemplateQuestion, TemplateStep, ValueRef } from "./template-json.js";
|
|
2
|
+
export type TemplateContext = {
|
|
3
|
+
answers: Record<string, unknown>;
|
|
4
|
+
outDir: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function buildInitialContext(outDir: string, initialAnswers?: Record<string, unknown>): TemplateContext;
|
|
7
|
+
export declare function getRefValue(ctx: TemplateContext, ref: ValueRef): unknown;
|
|
8
|
+
export declare function evaluateCondition(ctx: TemplateContext, condition?: Condition): boolean;
|
|
9
|
+
export declare function interpolate(input: string, ctx: TemplateContext): string;
|
|
10
|
+
export declare function resolveTargetPath(outDir: string, target: string): string;
|
|
11
|
+
export declare function templateQuestions(template: JSONTemplate): TemplateQuestion[];
|
|
12
|
+
export declare function templateSteps(template: JSONTemplate): TemplateStep[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export function buildInitialContext(outDir, initialAnswers) {
|
|
3
|
+
return {
|
|
4
|
+
answers: { ...(initialAnswers ?? {}) },
|
|
5
|
+
outDir,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function getRefValue(ctx, ref) {
|
|
9
|
+
const refPath = ref.ref;
|
|
10
|
+
if (!refPath)
|
|
11
|
+
return undefined;
|
|
12
|
+
const parts = refPath.split(".");
|
|
13
|
+
let current = ctx;
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
if (current == null)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (typeof current !== "object")
|
|
18
|
+
return undefined;
|
|
19
|
+
const record = current;
|
|
20
|
+
current = record[part];
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
export function evaluateCondition(ctx, condition) {
|
|
25
|
+
if (!condition)
|
|
26
|
+
return true;
|
|
27
|
+
switch (condition.op) {
|
|
28
|
+
case "eq":
|
|
29
|
+
return getRefValue(ctx, condition.left) === condition.right;
|
|
30
|
+
case "neq":
|
|
31
|
+
return getRefValue(ctx, condition.left) !== condition.right;
|
|
32
|
+
case "in": {
|
|
33
|
+
const value = getRefValue(ctx, condition.left);
|
|
34
|
+
return Array.isArray(condition.right) && condition.right.includes(value);
|
|
35
|
+
}
|
|
36
|
+
case "notIn": {
|
|
37
|
+
const value = getRefValue(ctx, condition.left);
|
|
38
|
+
return Array.isArray(condition.right) && !condition.right.includes(value);
|
|
39
|
+
}
|
|
40
|
+
case "truthy":
|
|
41
|
+
return Boolean(getRefValue(ctx, condition.value));
|
|
42
|
+
case "falsy":
|
|
43
|
+
return !getRefValue(ctx, condition.value);
|
|
44
|
+
case "exists": {
|
|
45
|
+
const value = getRefValue(ctx, condition.value);
|
|
46
|
+
return value !== undefined && value !== null;
|
|
47
|
+
}
|
|
48
|
+
case "and":
|
|
49
|
+
return condition.conditions.every((c) => evaluateCondition(ctx, c));
|
|
50
|
+
case "or":
|
|
51
|
+
return condition.conditions.some((c) => evaluateCondition(ctx, c));
|
|
52
|
+
case "not":
|
|
53
|
+
return !evaluateCondition(ctx, condition.condition);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const INTERPOLATION = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
57
|
+
export function interpolate(input, ctx) {
|
|
58
|
+
return input.replace(INTERPOLATION, (_match, expr) => {
|
|
59
|
+
const trimmed = String(expr ?? "").trim();
|
|
60
|
+
// Support {{answers.foo}} and {{outDir}}
|
|
61
|
+
if (trimmed === "outDir")
|
|
62
|
+
return ctx.outDir;
|
|
63
|
+
// Any other expression is treated as a ref path
|
|
64
|
+
const value = getRefValue(ctx, { ref: trimmed });
|
|
65
|
+
return value == null ? "" : String(value);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function resolveTargetPath(outDir, target) {
|
|
69
|
+
return path.isAbsolute(target) ? target : path.resolve(outDir, target);
|
|
70
|
+
}
|
|
71
|
+
export function templateQuestions(template) {
|
|
72
|
+
return template.questions ?? [];
|
|
73
|
+
}
|
|
74
|
+
export function templateSteps(template) {
|
|
75
|
+
return template.steps;
|
|
76
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type JSONTemplate = {
|
|
2
|
+
author?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
questions?: TemplateQuestion[];
|
|
7
|
+
steps: TemplateStep[];
|
|
8
|
+
};
|
|
9
|
+
export type TemplateQuestion = {
|
|
10
|
+
id: string;
|
|
11
|
+
type: "input" | "confirm" | "select" | "multiselect";
|
|
12
|
+
message: string;
|
|
13
|
+
default?: string | boolean | string[];
|
|
14
|
+
required?: boolean;
|
|
15
|
+
options?: Array<{
|
|
16
|
+
label: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}>;
|
|
19
|
+
when?: Condition;
|
|
20
|
+
};
|
|
21
|
+
export type TemplateStep = CopyStep | MergeStep | EnvironmentStep | CommandStep;
|
|
22
|
+
type StepBase = {
|
|
23
|
+
when?: Condition;
|
|
24
|
+
};
|
|
25
|
+
export type CopyStep = StepBase & {
|
|
26
|
+
type: "copy";
|
|
27
|
+
source: string;
|
|
28
|
+
target: string;
|
|
29
|
+
exclude?: string[];
|
|
30
|
+
};
|
|
31
|
+
export type MergeStep = StepBase & {
|
|
32
|
+
type: "merge";
|
|
33
|
+
source: string;
|
|
34
|
+
target: string;
|
|
35
|
+
merge?: {
|
|
36
|
+
format?: "json" | "yaml" | "text";
|
|
37
|
+
strategy?: "deep" | "shallow" | "append" | "prepend";
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export type EnvironmentStep = StepBase & {
|
|
41
|
+
type: "environment";
|
|
42
|
+
target: string;
|
|
43
|
+
variables: Record<string, string>;
|
|
44
|
+
};
|
|
45
|
+
export type CommandStep = StepBase & {
|
|
46
|
+
type: "command";
|
|
47
|
+
command: string;
|
|
48
|
+
workingDirectory?: string;
|
|
49
|
+
};
|
|
50
|
+
export type ValueRef = {
|
|
51
|
+
ref: string;
|
|
52
|
+
};
|
|
53
|
+
export type Condition = {
|
|
54
|
+
op: "eq" | "neq";
|
|
55
|
+
left: ValueRef;
|
|
56
|
+
right: unknown;
|
|
57
|
+
} | {
|
|
58
|
+
op: "in" | "notIn";
|
|
59
|
+
left: ValueRef;
|
|
60
|
+
right: unknown[];
|
|
61
|
+
} | {
|
|
62
|
+
op: "truthy" | "falsy" | "exists";
|
|
63
|
+
value: ValueRef;
|
|
64
|
+
} | {
|
|
65
|
+
op: "and" | "or";
|
|
66
|
+
conditions: Condition[];
|
|
67
|
+
} | {
|
|
68
|
+
op: "not";
|
|
69
|
+
condition: Condition;
|
|
70
|
+
};
|
|
71
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"commands": {
|
|
3
|
+
"init": {
|
|
4
|
+
"aliases": [],
|
|
5
|
+
"args": {},
|
|
6
|
+
"description": "Scaffold a project from a JSON template",
|
|
7
|
+
"examples": [
|
|
8
|
+
"<%= config.bin %> <%= command.id %> --template ./docs/examples/minimal.template.json --out ./my-app",
|
|
9
|
+
"<%= config.bin %> <%= command.id %> -t template.json -o . --answer projectDir=./my-app --defaults"
|
|
10
|
+
],
|
|
11
|
+
"flags": {
|
|
12
|
+
"template": {
|
|
13
|
+
"char": "t",
|
|
14
|
+
"description": "Path or URL to a template JSON file",
|
|
15
|
+
"name": "template",
|
|
16
|
+
"required": true,
|
|
17
|
+
"hasDynamicHelp": false,
|
|
18
|
+
"multiple": false,
|
|
19
|
+
"type": "option"
|
|
20
|
+
},
|
|
21
|
+
"out": {
|
|
22
|
+
"char": "o",
|
|
23
|
+
"description": "Base output directory (relative targets resolve from here)",
|
|
24
|
+
"name": "out",
|
|
25
|
+
"default": ".",
|
|
26
|
+
"hasDynamicHelp": false,
|
|
27
|
+
"multiple": false,
|
|
28
|
+
"type": "option"
|
|
29
|
+
},
|
|
30
|
+
"answer": {
|
|
31
|
+
"description": "Provide an answer: --answer key=value (repeatable)",
|
|
32
|
+
"name": "answer",
|
|
33
|
+
"hasDynamicHelp": false,
|
|
34
|
+
"multiple": true,
|
|
35
|
+
"type": "option"
|
|
36
|
+
},
|
|
37
|
+
"defaults": {
|
|
38
|
+
"description": "Do not prompt; use defaults and provided --answer values",
|
|
39
|
+
"name": "defaults",
|
|
40
|
+
"allowNo": false,
|
|
41
|
+
"type": "boolean"
|
|
42
|
+
},
|
|
43
|
+
"dryRun": {
|
|
44
|
+
"description": "Print actions without writing files or running commands",
|
|
45
|
+
"name": "dryRun",
|
|
46
|
+
"allowNo": false,
|
|
47
|
+
"type": "boolean"
|
|
48
|
+
},
|
|
49
|
+
"conflict": {
|
|
50
|
+
"description": "When a target already exists: error|skip|overwrite|prompt",
|
|
51
|
+
"name": "conflict",
|
|
52
|
+
"default": "prompt",
|
|
53
|
+
"hasDynamicHelp": false,
|
|
54
|
+
"multiple": false,
|
|
55
|
+
"options": [
|
|
56
|
+
"error",
|
|
57
|
+
"skip",
|
|
58
|
+
"overwrite",
|
|
59
|
+
"prompt"
|
|
60
|
+
],
|
|
61
|
+
"type": "option"
|
|
62
|
+
},
|
|
63
|
+
"force": {
|
|
64
|
+
"description": "Overwrite existing files when copying",
|
|
65
|
+
"name": "force",
|
|
66
|
+
"allowNo": false,
|
|
67
|
+
"type": "boolean"
|
|
68
|
+
},
|
|
69
|
+
"allowMissingEnv": {
|
|
70
|
+
"description": "Do not fail if an environment variable is missing for an environment step",
|
|
71
|
+
"name": "allowMissingEnv",
|
|
72
|
+
"allowNo": false,
|
|
73
|
+
"type": "boolean"
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"hasDynamicHelp": false,
|
|
77
|
+
"hiddenAliases": [],
|
|
78
|
+
"id": "init",
|
|
79
|
+
"pluginAlias": "harbor-templater",
|
|
80
|
+
"pluginName": "harbor-templater",
|
|
81
|
+
"pluginType": "core",
|
|
82
|
+
"strict": true,
|
|
83
|
+
"enableJsonFlag": false,
|
|
84
|
+
"isESM": true,
|
|
85
|
+
"relativePath": [
|
|
86
|
+
"dist",
|
|
87
|
+
"commands",
|
|
88
|
+
"init",
|
|
89
|
+
"index.js"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"version": "0.0.0"
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "harbor-templater",
|
|
3
|
+
"description": "A CLI tool for scaffolding projects using Harbor templates",
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"author": "Ben Di Giorgio",
|
|
6
|
+
"bin": {
|
|
7
|
+
"harbor-templater": "./bin/run.js"
|
|
8
|
+
},
|
|
9
|
+
"bugs": "https://github.com/bendigiorgio/harbor-templater/issues",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@inquirer/prompts": "^8.2.0",
|
|
12
|
+
"@oclif/core": "^4",
|
|
13
|
+
"@oclif/plugin-help": "^6",
|
|
14
|
+
"@oclif/plugin-plugins": "^5",
|
|
15
|
+
"picomatch": "^4.0.3",
|
|
16
|
+
"tar": "^7.5.3"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@biomejs/biome": "^2.3.11",
|
|
20
|
+
"@commitlint/cli": "^20.3.1",
|
|
21
|
+
"@commitlint/config-conventional": "^20.3.1",
|
|
22
|
+
"@eslint/compat": "^1",
|
|
23
|
+
"@oclif/prettier-config": "^0.2.1",
|
|
24
|
+
"@oclif/test": "^4",
|
|
25
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
26
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
27
|
+
"@semantic-release/exec": "^7.1.0",
|
|
28
|
+
"@semantic-release/git": "^10.0.1",
|
|
29
|
+
"@semantic-release/github": "^12.0.2",
|
|
30
|
+
"@semantic-release/npm": "^13.1.3",
|
|
31
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
32
|
+
"@types/chai": "^4",
|
|
33
|
+
"@types/mocha": "^10",
|
|
34
|
+
"@types/node": "^24.10.9",
|
|
35
|
+
"@types/picomatch": "^4.0.2",
|
|
36
|
+
"chai": "^4",
|
|
37
|
+
"eslint": "^9",
|
|
38
|
+
"eslint-config-oclif": "^6",
|
|
39
|
+
"eslint-config-prettier": "^10",
|
|
40
|
+
"husky": "^9.1.7",
|
|
41
|
+
"mocha": "^10",
|
|
42
|
+
"oclif": "^4",
|
|
43
|
+
"semantic-release": "^25.0.2",
|
|
44
|
+
"shx": "^0.3.3",
|
|
45
|
+
"ts-node": "^10",
|
|
46
|
+
"typescript": "^5"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"./bin",
|
|
53
|
+
"./dist",
|
|
54
|
+
"./oclif.manifest.json"
|
|
55
|
+
],
|
|
56
|
+
"homepage": "https://github.com/bendigiorgio/harbor-templater",
|
|
57
|
+
"keywords": [
|
|
58
|
+
"oclif"
|
|
59
|
+
],
|
|
60
|
+
"license": "MIT",
|
|
61
|
+
"main": "dist/index.js",
|
|
62
|
+
"type": "module",
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"oclif": {
|
|
67
|
+
"bin": "harbor-templater",
|
|
68
|
+
"dirname": "harbor-templater",
|
|
69
|
+
"commands": "./dist/commands",
|
|
70
|
+
"plugins": [
|
|
71
|
+
"@oclif/plugin-help",
|
|
72
|
+
"@oclif/plugin-plugins"
|
|
73
|
+
],
|
|
74
|
+
"topicSeparator": " "
|
|
75
|
+
},
|
|
76
|
+
"repository": "bendigiorgio/harbor-templater",
|
|
77
|
+
"scripts": {
|
|
78
|
+
"build": "shx rm -rf dist && shx rm -f tsconfig.tsbuildinfo && tsc -p tsconfig.json",
|
|
79
|
+
"format": "biome format",
|
|
80
|
+
"lint": "biome lint",
|
|
81
|
+
"check": "biome check",
|
|
82
|
+
"lint:fix": "biome lint --fix",
|
|
83
|
+
"postpack": "shx rm -f oclif.manifest.json",
|
|
84
|
+
"posttest": "pnpm run lint",
|
|
85
|
+
"prepack": "pnpm run build && oclif manifest && oclif readme",
|
|
86
|
+
"prepublishOnly": "pnpm test && pnpm run build && pnpm run prepack",
|
|
87
|
+
"prepare": "husky",
|
|
88
|
+
"commitlint": "commitlint --edit",
|
|
89
|
+
"release": "semantic-release",
|
|
90
|
+
"test": "mocha --forbid-only \"test/**/*.test.ts\"",
|
|
91
|
+
"version": "oclif readme && git add README.md"
|
|
92
|
+
},
|
|
93
|
+
"types": "dist/index.d.ts",
|
|
94
|
+
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
|
95
|
+
}
|