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 +21 -0
- package/README.md +87 -2
- package/bin/create-uno-blueprint.mjs +41 -0
- package/package.json +32 -4
- package/src/node-floor.mjs +11 -0
- package/src/run.mjs +446 -0
- package/src/tar.mjs +128 -0
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
|
-
#
|
|
1
|
+
# create-uno-blueprint
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|