create-uno-blueprint 0.0.0-stage → 2.5.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BilLogic (Bill & Meryem)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # create-uno-blueprint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Start an [Uno Blueprint](https://github.com/BilLogic/uno-blueprint) workspace
4
+ with one command.
5
+
6
+ Uno Blueprint turns a service blueprint into structured, queryable data that
7
+ agents and people read from the same place. This package is its initialiser:
8
+ it writes the whole template into a folder, at the release that matches its
9
+ own version, and tells you what to type next.
10
+
11
+ ## Use it
12
+
13
+ Every package manager maps `create uno-blueprint` to this package, so the
14
+ command has the same shape in each:
15
+
16
+ ```bash
17
+ npm create uno-blueprint@latest my-blueprint
18
+ pnpm create uno-blueprint my-blueprint
19
+ yarn create uno-blueprint my-blueprint # Yarn 1
20
+ bun create uno-blueprint my-blueprint
21
+ ```
22
+
23
+ `my-blueprint` is the folder the workspace is written into. Leave it out and
24
+ the folder is `uno-blueprint`. Give `.` to write into the current folder. The
25
+ folder must be empty or not exist yet: one that already has files in it is
26
+ refused, and nothing of yours is overwritten.
27
+
28
+ With Yarn, that means Yarn 1. Yarn 2 and later cannot run the template. Under
29
+ them the workspace is still written and nothing is installed: asked to
30
+ install, the command exits with an error naming the package managers that can;
31
+ with `--no-install`, it says so in one line, prints the next steps for npm,
32
+ and succeeds.
33
+
34
+ The command asks no questions, so an agent can run it unattended. It ends by
35
+ printing the commands that start the canvas.
36
+
37
+ ## Options
38
+
39
+ ```text
40
+ create-uno-blueprint [directory] [options]
41
+ ```
42
+
43
+ | Option | What it does |
44
+ | --- | --- |
45
+ | `--no-install` | Write the workspace and skip installing dependencies. |
46
+ | `--help` | Show the usage. |
47
+ | `--version` | Show the version. |
48
+
49
+ With npm, options go after a `--`:
50
+
51
+ ```bash
52
+ npm create uno-blueprint@latest my-blueprint -- --no-install
53
+ ```
54
+
55
+ The exit code is 0 only when the workspace is complete. Every refusal is one
56
+ line on stderr and exit code 1.
57
+
58
+ ## What it writes
59
+
60
+ The template at the release tagged `v<version>`, where `<version>` is this
61
+ package's own. The initialiser and the template share one version number, so
62
+ `create-uno-blueprint --version` is the release you started from and that
63
+ release's upgrade recipe applies.
64
+
65
+ The workspace is a full copy: the app, the four `ub` skills, and the
66
+ references, agents and scripts they read. The one thing left out is this
67
+ initialiser's own folder.
68
+
69
+ It downloads the release tarball from GitHub and unpacks it itself. It needs
70
+ neither git nor a system `tar`, and it has no dependencies.
71
+
72
+ ## Requirements
73
+
74
+ - **Node 22 or later.** An older Node is refused first, with a message naming
75
+ the version needed.
76
+ - **Network access to `codeload.github.com`**, which serves the release. A
77
+ download that fails says where it tried.
78
+
79
+ ## More
80
+
81
+ - The template, its documentation and its issues:
82
+ <https://github.com/BilLogic/uno-blueprint>
83
+ - Published from that repository's release tags. Every version after the
84
+ first carries provenance, so you can see it was built there.
85
+
86
+ ## Licence
87
+
88
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The command. Everything it does is `run`, handed the real process by
4
+ * `runInProcess` beside it, so a test can hand it a throwaway one.
5
+ *
6
+ * WRITTEN IN THE SYNTAX AN OLD NODE PARSES, and that is the whole reason it
7
+ * looks the way it does. `run` checks the Node floor first — but a Node old
8
+ * enough fails to PARSE the module `run` lives in, and says `SyntaxError`
9
+ * about an operator instead of which Node is needed. So the floor is checked
10
+ * here as well, in nothing newer than `var`, function expressions, a static
11
+ * `import` of the floor and a dynamic `import()` of the entry — the last only
12
+ * once the check has passed. That is the module syntax any Node that can load
13
+ * this file at all already parses. The floor is `src/node-floor.mjs`, which
14
+ * `run` reads too; both manifests state it in `engines`.
15
+ */
16
+ import { NODE_FLOOR } from '../src/node-floor.mjs'
17
+
18
+ function lastResort(error) {
19
+ // Nothing should arrive here: `run` reports its own failures in one line.
20
+ // What does is still owed one line and a failing exit, not a stack trace.
21
+ var why = error && error.message ? error.message : String(error)
22
+ process.stderr.write('create-uno-blueprint: ' + why.replace(/\s+/g, ' ') + '\n')
23
+ process.exitCode = 1
24
+ }
25
+
26
+ // "Not at least", so a version that cannot be read is refused too.
27
+ if (!(parseInt(process.versions.node, 10) >= NODE_FLOOR)) {
28
+ process.stderr.write(
29
+ 'create-uno-blueprint: Node ' + NODE_FLOOR + ' or later is needed; this is Node ' + process.versions.node + '.\n'
30
+ )
31
+ process.exitCode = 1
32
+ } else {
33
+ import('../src/run.mjs')
34
+ .then(function (entry) {
35
+ return entry.runInProcess()
36
+ })
37
+ .then(function (code) {
38
+ process.exitCode = code
39
+ })
40
+ .catch(lastResort)
41
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "create-uno-blueprint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.5.1",
4
+ "description": "Start an Uno Blueprint workspace with one command.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-uno-blueprint": "bin/create-uno-blueprint.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "license": "MIT",
17
+ "homepage": "https://github.com/BilLogic/uno-blueprint/tree/main/packages/create-uno-blueprint#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/BilLogic/uno-blueprint/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/BilLogic/uno-blueprint.git",
24
+ "directory": "packages/create-uno-blueprint"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "keywords": [
30
+ "service-blueprint",
31
+ "service-design",
32
+ "uno-blueprint"
33
+ ]
34
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The oldest Node the template runs on, in one place for the two that check
3
+ * it: the bin, before it loads anything else, and `run`. The package manifest
4
+ * and the root manifest state it again in `engines`, and a test holds them to
5
+ * this.
6
+ *
7
+ * WRITTEN FOR ANY NODE THAT CAN LOAD A MODULE AT ALL. The bin imports this
8
+ * before it knows which Node it is on, so nothing here may be newer than the
9
+ * bin itself: `export` and `var`.
10
+ */
11
+ export var NODE_FLOOR = 22
package/src/run.mjs ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * `create-uno-blueprint`: one command that writes an Uno Blueprint workspace.
3
+ *
4
+ * A workspace is the whole template at one release — the release whose tag is
5
+ * this package's own version, so the number a user installed is the number
6
+ * they started from. It is downloaded as the tarball GitHub serves for that
7
+ * tag and unpacked here: gunzip from the standard library, the tar reader
8
+ * beside this file. No git, no system tar, no runtime dependency, no prompt.
9
+ *
10
+ * ONE FUNCTION IS THE WHOLE INTERFACE. `run` takes everything it would
11
+ * otherwise reach for — arguments, environment, working directory, the two
12
+ * streams, the Node version, where the tarball comes from, what installs the
13
+ * dependencies — and returns the exit code. The bin hands it the real process;
14
+ * a test hands it a temporary folder, a tarball built in memory and an install
15
+ * that only records the call, and reads back what a user would see. The two
16
+ * other exports are the halves of that hand-over the bin does not write out
17
+ * itself: `runInProcess`, which is `run` over the real process, and
18
+ * `installWith`, which is the install when nothing replaces it.
19
+ *
20
+ * Once the files are written it installs their dependencies with the package
21
+ * manager that called it, and ends by saying what to type next in that
22
+ * package manager's own words.
23
+ */
24
+ import { spawn } from 'node:child_process'
25
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
26
+ import { dirname, join, resolve } from 'node:path'
27
+ import { gunzipSync } from 'node:zlib'
28
+
29
+ import { NODE_FLOOR } from './node-floor.mjs'
30
+ import { readTar } from './tar.mjs'
31
+
32
+ const NAME = 'create-uno-blueprint'
33
+
34
+ /** This package's version, which is the template's: the version guard holds the two together. */
35
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
36
+
37
+ const DEFAULT_DIRECTORY = 'uno-blueprint'
38
+
39
+ /**
40
+ * Where this package sits in the template, which is the one folder a
41
+ * workspace does not get: it has already done its work by the time the
42
+ * workspace exists.
43
+ */
44
+ const OWN_FOLDER = ['packages', 'create-uno-blueprint']
45
+
46
+ const USAGE = `Usage: ${NAME} [directory] [options]
47
+
48
+ Writes an Uno Blueprint workspace, the template at release ${VERSION}, into
49
+ [directory], and installs its dependencies with the package manager that ran
50
+ this command. The folder must be empty or not exist yet. Default: ${DEFAULT_DIRECTORY}
51
+
52
+ Options:
53
+ --no-install Write the workspace and skip installing dependencies
54
+ --help Show this message
55
+ --version Show the version
56
+ `
57
+
58
+ /** The tarball GitHub serves for this version's release tag. */
59
+ const RELEASE_URL = `https://codeload.github.com/BilLogic/uno-blueprint/tar.gz/refs/tags/v${VERSION}`
60
+
61
+ /**
62
+ * Two bounds on what comes back from the network, because nothing here has
63
+ * seen it before. A connection that stalls would otherwise be a command that
64
+ * never ends, and a few kilobytes of gzip can unpack to more than a machine
65
+ * holds. The template is about a tenth of the second; both are far enough out
66
+ * that neither is met by a release.
67
+ */
68
+ const DOWNLOAD_TIMEOUT_MS = 60_000
69
+ const UNPACKED_LIMIT_MB = 128
70
+
71
+ /** Download a tarball with the platform fetch. Throws on anything but a 2xx, and on the timeout. */
72
+ async function fetchRelease(url) {
73
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
74
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
75
+ return new Uint8Array(await response.arrayBuffer())
76
+ }
77
+
78
+ /**
79
+ * The package managers a workspace installs and runs under. One entry is
80
+ * everything said about a manager: the command, what it is given to install,
81
+ * and what it is given to start the canvas. What is run and what is printed
82
+ * are both read from here, so the line a user is told to type is the line
83
+ * that was typed for them.
84
+ *
85
+ * `npm` is the one that needs `run`; the other three take a script's name as
86
+ * a command of their own.
87
+ */
88
+ const PACKAGE_MANAGERS = {
89
+ npm: { command: 'npm', install: ['install'], dev: ['run', 'dev'] },
90
+ pnpm: { command: 'pnpm', install: ['install'], dev: ['dev'] },
91
+ yarn: { command: 'yarn', install: ['install'], dev: ['dev'] },
92
+ bun: { command: 'bun', install: ['install'], dev: ['dev'] },
93
+ }
94
+
95
+ /** A command and its arguments as the line a user types. */
96
+ const typed = (command, args) => [command, ...args].join(' ')
97
+
98
+ /**
99
+ * Which package manager called. `npm create`, `pnpm create`, `yarn create`
100
+ * and `bun create` each put their name and version first in
101
+ * `npm_config_user_agent`, as `pnpm/10.1.0 npm/? node/v22.12.0 …`. Only that
102
+ * first word is read: the three that are not npm name npm further along, to
103
+ * say what they stand in for. Run by hand, or by anything else, it is npm.
104
+ *
105
+ * YARN IS TWO PROGRAMS UNDER ONE NAME, and the version is what tells them
106
+ * apart. Yarn 1 runs this template. Yarn 2 and later do not run the `pre`
107
+ * scripts `dev` and `build` rely on and install without a `node_modules` by
108
+ * default, so an install there would exit 0 over a workspace that then does
109
+ * not start. That caller is answered with `unsupported` and what it calls
110
+ * itself, and nothing is installed. A Yarn that states no version is taken
111
+ * for the first.
112
+ */
113
+ function callingPackageManager(env) {
114
+ const [name, version] = String(env?.npm_config_user_agent ?? '').trim().split(/\s+/)[0].split('/')
115
+ if (!Object.hasOwn(PACKAGE_MANAGERS, name)) return { pm: 'npm' }
116
+ const major = parseInt(version, 10)
117
+ if (name === 'yarn' && major >= 2) return { pm: name, unsupported: `Yarn ${major}` }
118
+ return { pm: name }
119
+ }
120
+
121
+ /**
122
+ * Install a workspace's dependencies: run `command` with `args` in `cwd`,
123
+ * its output going straight to the terminal the command was run in.
124
+ * Resolves with the exit code. Rejects when it could not be started at all,
125
+ * and when a signal ended it, which is not an install that finished.
126
+ *
127
+ * The command and arguments are taken as words, never as anything a user
128
+ * typed: on Windows they are joined into one string for a shell, unquoted.
129
+ * `run` passes an entry of the table above, whose words need no quoting, and
130
+ * any other caller has to pass words that do not either.
131
+ *
132
+ * @param {object} request
133
+ * @param {string} request.command a package manager's command from the table, or another word that needs no quoting
134
+ * @param {string[]} request.args its arguments, on the same terms
135
+ * @param {string} request.cwd
136
+ * @param {import('node:child_process').StdioOptions} [request.stdio]
137
+ * @returns {Promise<number>}
138
+ */
139
+ export function installWith({ command, args, cwd, stdio = 'inherit' }) {
140
+ return new Promise((resolveCode, reject) => {
141
+ // On Windows a package manager is a `.cmd` shim, which only a shell runs,
142
+ // and a shell is handed one string rather than a list it would have to
143
+ // join unquoted. Safe for the words this is given, which the comment
144
+ // above holds every caller to.
145
+ const child =
146
+ process.platform === 'win32'
147
+ ? spawn(typed(command, args), { cwd, stdio, shell: true })
148
+ : spawn(command, args, { cwd, stdio })
149
+ child.on('error', reject)
150
+ child.on('close', (code, signal) =>
151
+ code === null ? reject(new Error(`ended by ${signal}`)) : resolveCode(code),
152
+ )
153
+ })
154
+ }
155
+
156
+ /**
157
+ * The command as its bin runs it: `run`, handed the real process. Anything in
158
+ * `overrides` replaces what the process would have supplied, which is how the
159
+ * helper that writes a workspace from a checkout swaps the download and
160
+ * nothing else.
161
+ *
162
+ * @param {Partial<Parameters<typeof run>[0]>} [overrides]
163
+ * @returns {Promise<number>} the exit code
164
+ */
165
+ export function runInProcess(overrides = {}) {
166
+ return run({
167
+ argv: process.argv.slice(2),
168
+ env: process.env,
169
+ cwd: process.cwd(),
170
+ stdout: process.stdout,
171
+ stderr: process.stderr,
172
+ nodeVersion: process.versions.node,
173
+ ...overrides,
174
+ })
175
+ }
176
+
177
+ /**
178
+ * Write a workspace and install it, or say in one line why not.
179
+ *
180
+ * @param {object} options
181
+ * @param {string[]} options.argv the arguments after the command's own name
182
+ * @param {Record<string, string | undefined>} options.env
183
+ * @param {string} options.cwd what a relative directory is resolved against
184
+ * @param {{ write(text: string): unknown }} options.stdout
185
+ * @param {{ write(text: string): unknown }} options.stderr
186
+ * @param {string} options.nodeVersion `process.versions.node`
187
+ * @param {(url: string) => Promise<Uint8Array>} [options.fetchTarball] the gzipped tarball at a URL
188
+ * @param {(request: { pm: 'npm' | 'pnpm' | 'yarn' | 'bun', command: string, args: string[], cwd: string }) => number | Promise<number>} [options.install]
189
+ * installs the dependencies of the workspace at `cwd` by running `command` with `args`, which are
190
+ * `pm`'s own, and answers with the exit code
191
+ * @returns {Promise<number>} the exit code: 0 only when the workspace is complete
192
+ */
193
+ export async function run({
194
+ argv,
195
+ env,
196
+ cwd,
197
+ stdout,
198
+ stderr,
199
+ nodeVersion,
200
+ fetchTarball = fetchRelease,
201
+ install = installWith,
202
+ }) {
203
+ const fail = (message) => {
204
+ stderr.write(`${NAME}: ${message}\n`)
205
+ return 1
206
+ }
207
+
208
+ // First, before the arguments are even read: every later line assumes it.
209
+ // Written as "not at least" so a version that cannot be read is refused
210
+ // rather than compared with nothing and let through.
211
+ if (!(parseInt(String(nodeVersion).replace(/^v/, ''), 10) >= NODE_FLOOR)) {
212
+ return fail(`Node ${NODE_FLOOR} or later is needed; this is Node ${nodeVersion}.`)
213
+ }
214
+
215
+ const asked = parseArguments(argv)
216
+ if (asked.fault) return fail(`${asked.fault} Run with --help to see what it takes.`)
217
+ if (asked.help) {
218
+ stdout.write(USAGE)
219
+ return 0
220
+ }
221
+ if (asked.version) {
222
+ stdout.write(`${VERSION}\n`)
223
+ return 0
224
+ }
225
+
226
+ const directory = asked.directory ?? DEFAULT_DIRECTORY
227
+ const target = resolve(cwd, directory)
228
+ /** Whether the workspace is the folder the command was run in, which changes how it is spoken of. */
229
+ const here = target === resolve(cwd)
230
+ // The highest folder this run will make: the target, or the first of its
231
+ // parents that is not there yet. It is what a failed write takes back.
232
+ let made = null
233
+ try {
234
+ const existed = existsSync(target)
235
+ if (existed && !statSync(target).isDirectory()) {
236
+ return fail(`${directory} exists and is not a folder.`)
237
+ }
238
+ if (existed && readdirSync(target).length > 0) {
239
+ return fail(
240
+ `${here ? 'this folder' : directory} already has files in it. Name an empty folder, or one that does not exist yet.`,
241
+ )
242
+ }
243
+ // `lstat`, not `exists`: a link that points nowhere is a thing that is
244
+ // there, and it is the user's. Followed, it reads as absent, and a failed
245
+ // write would take it back as though this run had made it. Anything but
246
+ // absence — a file where a folder has to go, a parent that cannot be
247
+ // searched — throws, and is answered below.
248
+ for (let path = target; lstatSync(path, { throwIfNoEntry: false }) === undefined; path = dirname(path)) {
249
+ made = path
250
+ }
251
+ // The folder the new ones go into has to be one, and one this user can
252
+ // write in. Asked now, so a target that can never be written costs no
253
+ // download.
254
+ if (made !== null) {
255
+ const parent = dirname(made)
256
+ if (!statSync(parent).isDirectory()) throw new Error(`${parent} is not a folder`)
257
+ accessSync(parent, constants.W_OK | constants.X_OK)
258
+ }
259
+ } catch (error) {
260
+ return fail(`could not use ${directory} (${reason(error)}).`)
261
+ }
262
+
263
+ const url = RELEASE_URL
264
+ let tarball
265
+ try {
266
+ tarball = await fetchTarball(url)
267
+ } catch (error) {
268
+ return fail(`could not download the template from ${url} (${reason(error)}).`)
269
+ }
270
+
271
+ // The whole archive is read and every path judged before the first byte is
272
+ // written, so a tarball that is refused leaves nothing behind.
273
+ let entries
274
+ try {
275
+ entries = workspaceEntries(
276
+ readTar(gunzipSync(tarball, { maxOutputLength: UNPACKED_LIMIT_MB * 1024 * 1024 })),
277
+ )
278
+ } catch (error) {
279
+ const why =
280
+ error?.code === 'ERR_BUFFER_TOO_LARGE'
281
+ ? `it unpacks to more than ${UNPACKED_LIMIT_MB} MB`
282
+ : reason(error)
283
+ return fail(`the template downloaded from ${url} could not be unpacked (${why}).`)
284
+ }
285
+
286
+ try {
287
+ for (const { segments, kind, mode, data } of entries) {
288
+ const path = join(target, ...segments)
289
+ if (kind === 'directory') {
290
+ mkdirSync(path, { recursive: true })
291
+ continue
292
+ }
293
+ mkdirSync(dirname(path), { recursive: true })
294
+ // Git records one thing about a file's mode — whether it can be run —
295
+ // so that is the one thing carried over; the umask decides the rest.
296
+ writeFileSync(path, data, { mode: mode & 0o111 ? 0o755 : 0o644 })
297
+ }
298
+ } catch (error) {
299
+ // The folder was empty or absent a moment ago, so all that is in it now
300
+ // is half a workspace, and leaving it would make the next run refuse.
301
+ // What goes is what this run made and nothing else: the folders it had to
302
+ // create on the way down, or, in a folder that was already there, what it
303
+ // put inside. Taking it back can fail for the reason the write did; the
304
+ // write's failure is the one worth reporting.
305
+ try {
306
+ if (made !== null) {
307
+ rmSync(made, { recursive: true, force: true })
308
+ } else {
309
+ for (const name of readdirSync(target)) rmSync(join(target, name), { recursive: true, force: true })
310
+ }
311
+ } catch {
312
+ // Nothing to add: the line below already says the workspace is not there.
313
+ }
314
+ return fail(`could not write the workspace to ${directory} (${reason(error)}).`)
315
+ }
316
+
317
+ const { pm, unsupported } = callingPackageManager(env)
318
+ const { command, install: installArgs } = PACKAGE_MANAGERS[pm]
319
+ const where = here ? 'this folder' : directory
320
+ stdout.write(`Uno Blueprint ${VERSION} is in ${where}.`)
321
+
322
+ // Yarn 2 and later get the workspace and not the install. Asked to install,
323
+ // that is a refusal; asked not to, it is what was asked for, and what is
324
+ // owed is who can install it and the steps in the words of one that can.
325
+ if (unsupported && asked.install) {
326
+ stdout.write('\n')
327
+ return fail(
328
+ `the workspace is in ${where}, but it was not installed: ${unsupported} cannot run it. It runs under npm, pnpm, Bun and Yarn 1, so install with one of those in ${where}.`,
329
+ )
330
+ }
331
+ if (unsupported) {
332
+ stdout.write(`\n${unsupported} cannot install or run it; npm, pnpm, Bun or Yarn 1 can, and the steps below are npm's.\n`)
333
+ stdout.write(nextSteps({ manager: PACKAGE_MANAGERS.npm, directory: here ? null : directory, installed: false }))
334
+ return 0
335
+ }
336
+
337
+ if (asked.install) {
338
+ // Said before the install rather than after, so the wait that follows has
339
+ // a reason on screen and the package manager's own output has a heading.
340
+ stdout.write(` Installing its dependencies with ${pm}.\n\n`)
341
+ // A package manager that exits non-zero, one that was killed and one that
342
+ // could not be started are the same failure here. Either way the files
343
+ // stay, and what is left to do in them is the line this names.
344
+ let why = null
345
+ try {
346
+ const code = await install({ pm, command, args: installArgs, cwd: target })
347
+ if (code !== 0) why = `exit code ${code}`
348
+ } catch (error) {
349
+ why = reason(error)
350
+ }
351
+ if (why !== null) {
352
+ return fail(
353
+ `the workspace is in ${where}, but ${typed(command, installArgs)} failed (${why}). Run it in ${where} to finish.`,
354
+ )
355
+ }
356
+ } else {
357
+ stdout.write('\n')
358
+ }
359
+
360
+ stdout.write(nextSteps({ manager: PACKAGE_MANAGERS[pm], directory: here ? null : directory, installed: asked.install }))
361
+ return 0
362
+ }
363
+
364
+ /**
365
+ * The lines left to type, in one manager's words: into the workspace unless
366
+ * it is the current folder (`directory` null), the install unless it was
367
+ * done here, and the line that starts the canvas.
368
+ */
369
+ function nextSteps({ manager, directory, installed }) {
370
+ const steps = [
371
+ ...(directory === null ? [] : [`cd ${directory}`]),
372
+ ...(installed ? [] : [typed(manager.command, manager.install)]),
373
+ typed(manager.command, manager.dev),
374
+ ]
375
+ return `\nNext steps:\n\n${steps.map((step) => ` ${step}\n`).join('')}`
376
+ }
377
+
378
+ /**
379
+ * What the arguments ask for: at most one directory, and the three flags.
380
+ * `-h` and `-v` are the short forms a person tries first.
381
+ */
382
+ function parseArguments(argv) {
383
+ const asked = { directory: undefined, install: true, help: false, version: false, fault: undefined }
384
+ for (const argument of argv) {
385
+ if (argument === '--help' || argument === '-h') asked.help = true
386
+ else if (argument === '--version' || argument === '-v') asked.version = true
387
+ else if (argument === '--no-install') asked.install = false
388
+ // An empty argument is what a script passes when its variable was unset.
389
+ else if (argument === '') continue
390
+ else if (argument.startsWith('-')) asked.fault ??= `unknown option ${argument}.`
391
+ else if (asked.directory !== undefined) asked.fault ??= `one directory at a time: got ${asked.directory} and ${argument}.`
392
+ else asked.directory = argument
393
+ }
394
+ return asked
395
+ }
396
+
397
+ /**
398
+ * The archive's entries as a workspace takes them: the wrapping top-level
399
+ * folder dropped, the initialiser's own folder left out, and every path
400
+ * proven to stay inside the target. Throws on the first entry that does not.
401
+ */
402
+ function workspaceEntries(archive) {
403
+ const entries = []
404
+ for (const { path, kind, mode, data } of archive) {
405
+ const segments = segmentsInsideTop(path)
406
+ if (segments.length === 0 || isAtOrBelow(segments, OWN_FOLDER)) continue
407
+ // A link can point anywhere, and a template has no use for one.
408
+ if (kind === 'other') throw new Error(`${path} is neither a file nor a folder`)
409
+ entries.push({ segments, kind, mode, data })
410
+ }
411
+ // A folder that held nothing but the initialiser is not written either: an
412
+ // empty `packages/` in a workspace would be a question with no answer.
413
+ return entries.filter(
414
+ ({ segments, kind }) =>
415
+ kind !== 'directory' ||
416
+ !isAtOrBelow(OWN_FOLDER, segments) ||
417
+ entries.some((other) => other.kind === 'file' && isAtOrBelow(other.segments, segments)),
418
+ )
419
+ }
420
+
421
+ /**
422
+ * An archive path as segments below its top-level folder, or a throw.
423
+ *
424
+ * A release tarball wraps the tree in one folder named after the tag, and
425
+ * that segment is dropped. What is left has to be a plain way down: an
426
+ * absolute path, a `..`, or a backslash — which Windows reads as a separator —
427
+ * is a tarball that is not the template, and none of it is written.
428
+ */
429
+ function segmentsInsideTop(path) {
430
+ const segments = path.split('/').filter((segment) => segment !== '' && segment !== '.')
431
+ if (path.startsWith('/') || segments.some((segment) => segment === '..' || segment.includes('\\'))) {
432
+ throw new Error(`${path} reaches outside the folder`)
433
+ }
434
+ return segments.slice(1)
435
+ }
436
+
437
+ /** Whether `path` is `folder` or somewhere beneath it, both as segments. */
438
+ function isAtOrBelow(path, folder) {
439
+ return folder.every((segment, index) => path[index] === segment)
440
+ }
441
+
442
+ /** Why something failed, on one line. A failed fetch keeps its cause one level down, so that is read too. */
443
+ function reason(error) {
444
+ const messages = [error?.message, error?.cause?.message].filter(Boolean)
445
+ return (messages.join(': ') || String(error)).replace(/\s+/g, ' ')
446
+ }
package/src/tar.mjs ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A tar reader, as small as a release tarball lets it be.
3
+ *
4
+ * The initialiser has no runtime dependencies and asks nothing of the machine
5
+ * it runs on, so it cannot reach for a package or for the system's `tar`. What
6
+ * it has to read is one kind of archive: what `git archive` writes and GitHub
7
+ * serves for a tag. That is ustar, with two additions this reader follows:
8
+ *
9
+ * - a pax GLOBAL header first, carrying the commit as a comment. Skipped.
10
+ * - a pax header ahead of any entry whose path fits neither the 100-byte
11
+ * name field nor the name and prefix fields together. Its `path` record
12
+ * is the entry's real path.
13
+ *
14
+ * The GNU long-name entry is read too, because it is the other way an
15
+ * archiver says the same thing and costs three lines.
16
+ *
17
+ * It reads; it does not judge. A path comes back exactly as the archive
18
+ * states it, `..` and all, and deciding what may be written where is the
19
+ * caller's.
20
+ */
21
+
22
+ const BLOCK = 512
23
+
24
+ const utf8 = new TextDecoder()
25
+
26
+ /**
27
+ * @typedef {object} TarEntry
28
+ * @property {string} path as the archive states it, top-level folder included
29
+ * @property {'file' | 'directory' | 'other'} kind `other` is anything that is neither: a link, a device
30
+ * @property {number} mode the permission bits
31
+ * @property {Uint8Array} data a file's content; empty for anything else
32
+ */
33
+
34
+ /**
35
+ * Every entry of an uncompressed tar, in archive order.
36
+ *
37
+ * Throws on bytes that are not a tar or that stop part-way, so a truncated
38
+ * download fails here rather than yielding a tree with its tail missing.
39
+ *
40
+ * @param {Uint8Array} bytes
41
+ * @returns {Generator<TarEntry>}
42
+ */
43
+ export function* readTar(bytes) {
44
+ let offset = 0
45
+ /** The path a pax or GNU header gave for the entry that follows it. */
46
+ let statedPath = null
47
+
48
+ while (offset + BLOCK <= bytes.length) {
49
+ const header = bytes.subarray(offset, offset + BLOCK)
50
+ // An all-zero block is the end marker. Whatever follows is padding.
51
+ if (header.every((byte) => byte === 0)) return
52
+ if (checksum(header) !== octal(header, 148, 8)) throw new Error('it is not a tar archive')
53
+
54
+ const size = octal(header, 124, 12)
55
+ const data = bytes.subarray(offset + BLOCK, offset + BLOCK + size)
56
+ if (data.length < size) break
57
+ offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK
58
+
59
+ const type = String.fromCharCode(header[156])
60
+ if (type === 'g') continue
61
+ if (type === 'x') {
62
+ statedPath = paxRecords(data).path ?? statedPath
63
+ continue
64
+ }
65
+ if (type === 'L') {
66
+ statedPath = text(data)
67
+ continue
68
+ }
69
+
70
+ const name = text(header.subarray(0, 100))
71
+ const prefix = text(header.subarray(257, 262)) === 'ustar' ? text(header.subarray(345, 500)) : ''
72
+ const path = statedPath ?? (prefix ? `${prefix}/${name}` : name)
73
+ statedPath = null
74
+
75
+ // A directory is type 5, or — in archives older than ustar — a plain
76
+ // entry whose name ends in a slash.
77
+ const isFile = type === '0' || type === '\0'
78
+ const kind = type === '5' || (isFile && path.endsWith('/')) ? 'directory' : isFile ? 'file' : 'other'
79
+ yield { path, kind, mode: octal(header, 100, 8), data: kind === 'file' ? data : data.subarray(0, 0) }
80
+ }
81
+
82
+ if (offset !== bytes.length) throw new Error('the archive ends in the middle of an entry')
83
+ }
84
+
85
+ /** A header field's text, up to its first NUL. */
86
+ function text(field) {
87
+ const end = field.indexOf(0)
88
+ return utf8.decode(end === -1 ? field : field.subarray(0, end))
89
+ }
90
+
91
+ /** A header field holding an octal number, padded with spaces or NULs. An empty field is zero. */
92
+ function octal(header, start, length) {
93
+ const digits = text(header.subarray(start, start + length)).trim()
94
+ if (digits === '') return 0
95
+ if (!/^[0-7]+$/.test(digits)) throw new Error('it is not a tar archive')
96
+ return parseInt(digits, 8)
97
+ }
98
+
99
+ /** A header's checksum: every byte summed, with the checksum field itself read as spaces. */
100
+ function checksum(header) {
101
+ let sum = 0
102
+ for (let index = 0; index < BLOCK; index += 1) {
103
+ sum += index >= 148 && index < 156 ? 0x20 : header[index]
104
+ }
105
+ return sum
106
+ }
107
+
108
+ /**
109
+ * The records of a pax header. Each is `<length> <key>=<value>\n`, where the
110
+ * length counts the whole record in bytes, its own digits included — so a
111
+ * value is read by length, never by looking for the newline it may contain.
112
+ */
113
+ function paxRecords(data) {
114
+ const records = {}
115
+ let at = 0
116
+ while (at < data.length) {
117
+ const space = data.indexOf(0x20, at)
118
+ const length = space === -1 ? NaN : Number(utf8.decode(data.subarray(at, space)))
119
+ if (!Number.isInteger(length) || length <= space - at || at + length > data.length) {
120
+ throw new Error('a pax header in it is malformed')
121
+ }
122
+ const record = utf8.decode(data.subarray(space + 1, at + length - 1))
123
+ const equals = record.indexOf('=')
124
+ records[record.slice(0, equals)] = record.slice(equals + 1)
125
+ at += length
126
+ }
127
+ return records
128
+ }