@eighty4/c2 0.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## Unreleased
2
+
3
+ - Evaluate env() and file() expressions in user data
4
+ - Merge multiple user data into a MIME multipart message
5
+
6
+ [Unreleased]: https://github.com/eighty4/c2/commits/main
package/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ Copyright 2025 Adam McKee Bennett <adam.be.g84d@gmail.com>
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
10
+
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Cloud Config
2
+
3
+ Blow up your `cloud-init` developer workflows!
4
+
5
+ ## Getting started
6
+
7
+ `c2` publishes only TypeScript requiring Node >=23 to run.
8
+
9
+ ```shell
10
+ npm i -g @eighty4/c2
11
+ c2 -h
12
+ ```
13
+
14
+ (tests use `bun:test` so [install Bun](https://bun.sh/docs/installation) for contributing!)
15
+
16
+ ## Using the CLI program
17
+
18
+ ```
19
+ c2 ./cloud_init_dir
20
+
21
+ # base64 encode your user data
22
+ c2 --base64 ./cloud_init_dir
23
+ ```
24
+
25
+ ## Using the JS API
26
+
27
+ ```
28
+ import { buildUserData } from '@eighty4/c2'
29
+
30
+ const userData: string = await buildUserData('./cloud_init_dir')
31
+ ```
32
+
33
+ ## Cloud Config data dir
34
+
35
+ Multiple user data are ordered by filenames and `01_` numbered prefixes help declaring execution order.
36
+
37
+ ```
38
+ ls ./cloud_init_dir
39
+ 01_upgrades.yml
40
+ 02_security.yml
41
+ 03_services.sh
42
+ ```
43
+
44
+ Shell scripts are supported as `x-shellscript` and YAML is included as `cloud-config` MIME types.
45
+
46
+ ## Evaluating expressions in user data
47
+
48
+ Scripts and YAML support two template functions that can be used in expressions.
49
+
50
+ ### env()
51
+
52
+ Replaces expression with content of a local environment variable.
53
+
54
+ ```bash
55
+ #!/bin/sh
56
+
57
+ ENV_VAR="${{ env('LOCAL_ENV_VAR') }}"
58
+ ```
59
+
60
+ ### file()
61
+
62
+ Looks up a file from your local filesystem and replaces the expression with its content.
63
+
64
+ ```yaml
65
+ #cloud-config
66
+
67
+ users:
68
+ - name: devops-baller
69
+ ssh_authorized_keys:
70
+ - ${{ file('~/.ssh/my_ssh_key.pub') }}
71
+ ```
72
+
73
+ Relative, absolute and `~/` paths are supported.
74
+
75
+ ## Contributing
76
+
77
+ I use `c2` for initializing Debian cloud instances and locally test with QEMU and Ubuntu.
78
+
79
+ Feedback on your use cases and worfklows is greatly appreciated!
package/c2.ts ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { buildUserData } from '#c2/build.ts'
4
+ import { parseArgs, type ParsedArgs } from '#c2/cli.ts'
5
+ import { doesDirExist } from '#c2/fs.ts'
6
+
7
+ let args: ParsedArgs | undefined
8
+ try {
9
+ args = parseArgs()
10
+ } catch (e: any) {
11
+ if (e.message) {
12
+ console.error(e.message)
13
+ }
14
+ }
15
+
16
+ if (!args || args.help) {
17
+ const optional = (s: string) => `\u001b[90m${s}\u001b[0m`
18
+ const required = (s: string) => `\u001b[1m${s}\u001b[0m`
19
+ errorExit(
20
+ `c2 ${optional('[[--base64] | [--http PORT]]')} ${required('USER_DATA_DIR')}`,
21
+ )
22
+ }
23
+
24
+ if (args.httpPort) {
25
+ errorExit('--http PORT is not yet implemented')
26
+ }
27
+
28
+ if (!(await doesDirExist(args.userDataDir))) {
29
+ errorExit(`${args.userDataDir} directory does not exist`)
30
+ }
31
+
32
+ try {
33
+ const userData = await buildUserData(args.userDataDir)
34
+ console.log(args.base64 ? btoa(userData) : userData)
35
+ } catch (e: any) {
36
+ errorExit(e.message)
37
+ }
38
+
39
+ function errorExit(msg: string): never {
40
+ console.error(msg)
41
+ process.exit(1)
42
+ }
@@ -0,0 +1,49 @@
1
+ import { readDirListing, readToString } from '#c2/fs.ts'
2
+
3
+ export type AttachmentType = 'cloud-config' | 'x-shellscript'
4
+
5
+ export interface Attachment {
6
+ path: string
7
+ content: string
8
+ filename: string
9
+ type: AttachmentType
10
+ }
11
+
12
+ export async function collectAttachments(
13
+ dir: string,
14
+ ): Promise<Array<Attachment>> {
15
+ const result: Array<Attachment> = []
16
+ for (const filename of await readDirListing(dir)) {
17
+ const path = `${dir}/${filename}`
18
+ const content = await readToString(path)
19
+ const type = resolveAttachmentType(filename, content)
20
+ result.push({ content, filename, path, type })
21
+ }
22
+ return result.sort(compareAttachmentFilenames)
23
+ }
24
+
25
+ function compareAttachmentFilenames(
26
+ a1: Attachment,
27
+ a2: Attachment,
28
+ ): 1 | 0 | -1 {
29
+ if (a1.filename === a2.filename) return 0
30
+ if (a1.filename > a2.filename) return 1
31
+ return -1
32
+ }
33
+
34
+ export function resolveAttachmentType(
35
+ filename: string,
36
+ content: string,
37
+ ): AttachmentType {
38
+ if (
39
+ filename.endsWith('.yml') ||
40
+ (filename.endsWith('.yaml') &&
41
+ content.trim().startsWith('#cloud-config'))
42
+ ) {
43
+ return 'cloud-config'
44
+ } else if (filename.endsWith('.sh')) {
45
+ return 'x-shellscript'
46
+ } else {
47
+ throw new Error(`unsupported file type ${filename}`)
48
+ }
49
+ }
package/lib/build.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { type Attachment, collectAttachments } from '#c2/attachments.ts'
2
+ import { evalTemplateExpressions } from '#c2/expression.ts'
3
+ import { readToString } from '#c2/fs.ts'
4
+
5
+ export type BuildUserDataOpts = {
6
+ attachmentBoundary?: string
7
+ }
8
+
9
+ export async function buildUserData(
10
+ userDataDir: string,
11
+ opts?: BuildUserDataOpts,
12
+ ): Promise<string> {
13
+ const attachments = await collectAttachments(userDataDir)
14
+ switch (attachments.length) {
15
+ case 0:
16
+ throw new Error(`nothing found in dir ${userDataDir}`)
17
+ case 1:
18
+ return evalTemplateExpressions(
19
+ await readToString(attachments[0].path),
20
+ )
21
+ default:
22
+ return buildMultipartUserData(attachments, opts?.attachmentBoundary)
23
+ }
24
+ }
25
+
26
+ function createBoundary(): string {
27
+ return new Date().toISOString()
28
+ }
29
+
30
+ async function buildMultipartUserData(
31
+ attachments: Array<Attachment>,
32
+ boundary: string = createBoundary(),
33
+ ): Promise<string> {
34
+ let result = `Content-Type: multipart/mixed; boundary=${boundary}
35
+ MIME-Version: 1.0
36
+ Number-Attachments: ${attachments.length}
37
+ --${boundary}
38
+ `
39
+
40
+ for (const attachment of attachments) {
41
+ let content: string
42
+ try {
43
+ content = await evalTemplateExpressions(attachment.content)
44
+ } catch (e: any) {
45
+ throw new Error(
46
+ `error templating ${attachment.filename}: ${e.message}`,
47
+ )
48
+ }
49
+ result += `Content-Type: text/${attachment.type}; charset="us-ascii"
50
+ MIME-Version: 1.0
51
+ Content-Transfer-Encoding: 7bit
52
+ Content-Disposition: attachment; filename="${attachment.filename}"
53
+
54
+ ${content}
55
+ --${boundary}
56
+ `
57
+ }
58
+ return result
59
+ }
package/lib/cli.ts ADDED
@@ -0,0 +1,48 @@
1
+ export type ParsedArgs =
2
+ | { help: true }
3
+ | {
4
+ help?: false
5
+ base64?: boolean
6
+ httpPort?: number
7
+ userDataDir: string
8
+ }
9
+
10
+ export function parseArgs(args?: Array<string>): ParsedArgs {
11
+ if (!args) {
12
+ args = process.argv
13
+ }
14
+ args = [...args]
15
+ while (!args.shift()!.endsWith('/c2.ts')) {}
16
+ let base64 = false
17
+ let httpPort: number | undefined
18
+ let userData: Array<string> = []
19
+ let expectHttpPort = false
20
+ for (const arg of args) {
21
+ if (expectHttpPort) {
22
+ expectHttpPort = false
23
+ httpPort = parseInt(arg, 10)
24
+ if (isNaN(httpPort)) {
25
+ throw new Error(`--http ${arg} is not a valid http port`)
26
+ }
27
+ } else if (arg === '-h' || arg === '--help') {
28
+ return { help: true }
29
+ } else if (arg === '--base64') {
30
+ base64 = true
31
+ } else if (arg === '--http') {
32
+ expectHttpPort = true
33
+ } else {
34
+ userData.push(arg)
35
+ }
36
+ }
37
+ switch (userData.length) {
38
+ case 1:
39
+ const userDataDir = userData[0]
40
+ if (typeof httpPort !== 'undefined') {
41
+ return { httpPort, userDataDir }
42
+ } else {
43
+ return { base64, userDataDir }
44
+ }
45
+ default:
46
+ throw new Error()
47
+ }
48
+ }
@@ -0,0 +1,65 @@
1
+ import MagicString from 'magic-string'
2
+ import { readToString } from '#c2/fs.ts'
3
+
4
+ type TemplateExpression = {
5
+ index: number
6
+ innie: string
7
+ outie: string
8
+ }
9
+
10
+ export async function evalTemplateExpressions(
11
+ content: string,
12
+ ): Promise<string> {
13
+ const regex = new RegExp(/\${{\s*(.*)\s*}}/g)
14
+ let match: RegExpExecArray | null
15
+ const expressions: Array<TemplateExpression> = []
16
+ while ((match = regex.exec(content)) != null) {
17
+ expressions.push({
18
+ index: match.index,
19
+ innie: match[1],
20
+ outie: match[0],
21
+ })
22
+ }
23
+ if (!expressions.length) {
24
+ return content
25
+ }
26
+ const ms = new MagicString(content)
27
+ for (const expression of expressions) {
28
+ ms.update(
29
+ expression.index,
30
+ expression.index + expression.outie.length,
31
+ await evaluate(expression.innie),
32
+ )
33
+ }
34
+ return ms.toString()
35
+ }
36
+
37
+ async function evaluate(expression: string): Promise<string> {
38
+ let match: RegExpMatchArray | null
39
+ if ((match = expression.match(/env\(\s*'(.*)'\s*\)/)) != null) {
40
+ const envVarKey = match[1]
41
+ if (!/[A-Z_]+/.test(envVarKey)) {
42
+ throw new Error(
43
+ `env var expression \`${envVarKey}\` is not valid syntax`,
44
+ )
45
+ }
46
+ const envVarValue = process.env[envVarKey]
47
+ if (!envVarValue) {
48
+ throw new Error(`env var \`${envVarKey}\` does not exist`)
49
+ }
50
+ return envVarValue
51
+ } else if ((match = expression.match(/file\(\s*'(.*)'\s*\)/)) != null) {
52
+ let path = match[1]
53
+ if (path.startsWith('~/')) {
54
+ if (!process.env.HOME) {
55
+ throw new Error(
56
+ `file \`${path}\` cannot be resolved without env var HOME`,
57
+ )
58
+ }
59
+ path = `${process.env.HOME}${path.substring(1)}`
60
+ }
61
+ return readToString(path)
62
+ }
63
+
64
+ throw new Error(`unsupported expression: ${expression}`)
65
+ }
package/lib/fs.bun.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { doesDirExist, readDirListing } from '#c2/fs.node.ts'
2
+
3
+ export async function readToString(p: string): Promise<string> {
4
+ return Bun.file(p).text()
5
+ }
package/lib/fs.node.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises'
2
+
3
+ export async function doesDirExist(p: string): Promise<boolean> {
4
+ try {
5
+ return (await stat(p)).isDirectory()
6
+ } catch (ignore) {
7
+ return false
8
+ }
9
+ }
10
+
11
+ export async function readDirListing(p: string): Promise<Array<string>> {
12
+ return readdir(p)
13
+ }
14
+
15
+ export async function readToString(p: string): Promise<string> {
16
+ return readFile(p, 'utf8')
17
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@eighty4/c2",
3
+ "version": "0.0.1",
4
+ "author": "Adam McKee <adam.be.g84d@gmail.com>",
5
+ "repository": "https://github.com/eighty4/c2",
6
+ "homepage": "https://github.com/eighty4/c2",
7
+ "keywords": [
8
+ "devops",
9
+ "cloud init",
10
+ "cloud config",
11
+ "user data"
12
+ ],
13
+ "description": "Cross platform cloud config tooling for cloud-init",
14
+ "license": "BSD-2-Clause",
15
+ "type": "module",
16
+ "engines": {
17
+ "bun": ">=1.2",
18
+ "node": ">=23"
19
+ },
20
+ "main": "lib/build.ts",
21
+ "bin": {
22
+ "c2": "./c2.ts"
23
+ },
24
+ "scripts": {
25
+ "fmtcheck": "prettier --check .",
26
+ "typecheck": "tsc"
27
+ },
28
+ "imports": {
29
+ "#c2/fs.ts": {
30
+ "bun": "./lib/fs.bun.ts",
31
+ "node": "./lib/fs.node.ts"
32
+ },
33
+ "#c2/*": "./lib/*"
34
+ },
35
+ "dependencies": {
36
+ "magic-string": "0.30.17"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "1.2.5",
40
+ "@types/node": "^22.14.1",
41
+ "prettier": "^3.5.3",
42
+ "typescript": "5.8.2"
43
+ },
44
+ "files": [
45
+ "lib/**/*.ts",
46
+ "!lib/**/*.spec.ts",
47
+ "!lib/fs.testing.ts",
48
+ "CHANGELOG.md",
49
+ "c2.ts"
50
+ ]
51
+ }