@eighty4/c2 0.0.2-9 → 0.0.2

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 CHANGED
@@ -1,6 +1,17 @@
1
- ## Unreleased
1
+ # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ - ???
6
+
7
+ ## [v0.0.2] - 2025-06-07
8
+
9
+ ### Added
10
+
11
+ - Serve user data over http with `c2 DIR --http PORT`
12
+ - Encode user data in base 64 with `c2 DIR --base64`
3
13
  - Evaluate env() and file() expressions in user data
4
14
  - Merge multiple user data into a MIME multipart message
5
15
 
6
- [Unreleased]: https://github.com/eighty4/c2/commits/main
16
+ [Unreleased]: https://github.com/eighty4/c2/compare/v0.0.2...HEAD
17
+ [v0.0.2]: https://github.com/eighty4/c2/releases/tag/v0.0.2
package/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  Blow up your `cloud-init` developer workflows!
4
4
 
5
+ See the [examples dir](https://github.com/eighty4/c2/tree/main/examples) for
6
+ examples running on your local machine or deploying to cloud providers.
7
+
5
8
  ## Getting started
6
9
 
7
10
  ```shell
@@ -9,7 +12,8 @@ npm i -g @eighty4/c2
9
12
  c2 -h
10
13
  ```
11
14
 
12
- (tests use `bun:test` so [install Bun](https://bun.sh/docs/installation) for contributing!)
15
+ (tests use `bun:test` so [install Bun](https://bun.sh/docs/installation)
16
+ for contributing!)
13
17
 
14
18
  ## Using the CLI program
15
19
 
@@ -30,7 +34,8 @@ const userData: string = await buildUserData('./cloud_init_dir')
30
34
 
31
35
  ## Cloud Config data dir
32
36
 
33
- Multiple user data are ordered by filenames and `01_` numbered prefixes help declaring execution order.
37
+ Multiple user data are ordered by filenames and `01_` numbered prefixes help
38
+ declare execution order.
34
39
 
35
40
  ```
36
41
  ls ./cloud_init_dir
@@ -39,11 +44,13 @@ ls ./cloud_init_dir
39
44
  03_services.sh
40
45
  ```
41
46
 
42
- Shell scripts are supported as `x-shellscript` and YAML is included as `cloud-config` MIME types.
47
+ Shell scripts are supported as `x-shellscript` and YAML is included as
48
+ `cloud-config` MIME types.
43
49
 
44
50
  ## Evaluating expressions in user data
45
51
 
46
- Scripts and YAML support two template functions that can be used in expressions.
52
+ Scripts and YAML support two template functions that can be used in
53
+ expressions.
47
54
 
48
55
  ### env()
49
56
 
@@ -57,7 +64,8 @@ ENV_VAR="${{ env('LOCAL_ENV_VAR') }}"
57
64
 
58
65
  ### file()
59
66
 
60
- Looks up a file from your local filesystem and replaces the expression with its content.
67
+ Looks up a file from your local filesystem and replaces the expression with
68
+ its content.
61
69
 
62
70
  ```yaml
63
71
  #cloud-config
@@ -72,6 +80,7 @@ Relative, absolute and `~/` paths are supported.
72
80
 
73
81
  ## Contributing
74
82
 
75
- I use `c2` for initializing Debian cloud instances and locally test with QEMU and Ubuntu.
83
+ I use `c2` for initializing Debian cloud instances and locally test with QEMU
84
+ and Ubuntu.
76
85
 
77
86
  Feedback on your use cases and worfklows is greatly appreciated!
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, expect, test } from 'bun:test'
2
2
  import { join } from 'node:path'
3
- import { collectAttachments } from '#c2/attachments.ts'
4
- import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing.ts'
3
+ import { collectAttachments } from '#c2/attachments'
4
+ import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing'
5
5
 
6
6
  let tmpDir: string
7
7
 
@@ -9,18 +9,53 @@ beforeEach(async () => (tmpDir = await makeTempDir()))
9
9
 
10
10
  afterEach(async () => await removeDir(tmpDir))
11
11
 
12
+ test('collect throws error for unknown filetype', async () => {
13
+ await makeFile('init-cloud', 'whoopie', tmpDir)
14
+ await expect(() => collectAttachments(tmpDir)).toThrow(
15
+ 'init-cloud is an unsupported file type',
16
+ )
17
+ })
18
+
12
19
  test('collect cloud config yml', async () => {
13
- await makeFile('init-cloud.yml', 'whoopie', tmpDir)
20
+ await makeFile('init-cloud.yml', '#cloud-config\nwhoopie', tmpDir)
14
21
  expect(await collectAttachments(tmpDir)).toStrictEqual([
15
22
  {
16
23
  path: join(tmpDir, 'init-cloud.yml'),
17
- content: 'whoopie',
24
+ content: '#cloud-config\nwhoopie',
18
25
  filename: 'init-cloud.yml',
26
+ source: '#cloud-config\nwhoopie',
19
27
  type: 'cloud-config',
20
28
  },
21
29
  ])
22
30
  })
23
31
 
32
+ test('collect cloud config yaml', async () => {
33
+ await makeFile('init-cloud.yaml', '#cloud-config\nwhoopie', tmpDir)
34
+ expect(await collectAttachments(tmpDir)).toStrictEqual([
35
+ {
36
+ path: join(tmpDir, 'init-cloud.yaml'),
37
+ content: '#cloud-config\nwhoopie',
38
+ filename: 'init-cloud.yaml',
39
+ source: '#cloud-config\nwhoopie',
40
+ type: 'cloud-config',
41
+ },
42
+ ])
43
+ })
44
+
45
+ test('collect throws error when yml does not have #cloud-config comment', async () => {
46
+ await makeFile('init-cloud.yml', 'whoopie', tmpDir)
47
+ await expect(() => collectAttachments(tmpDir)).toThrow(
48
+ 'YAML cloud config must start with a #cloud-config comment',
49
+ )
50
+ })
51
+
52
+ test('collect throws error when yaml does not have #cloud-config comment', async () => {
53
+ await makeFile('init-cloud.yaml', 'whoopie', tmpDir)
54
+ await expect(() => collectAttachments(tmpDir)).toThrow(
55
+ 'YAML cloud config must start with a #cloud-config comment',
56
+ )
57
+ })
58
+
24
59
  test('collect shell script', async () => {
25
60
  await makeFile('init-cloud.sh', 'whoopie', tmpDir)
26
61
  expect(await collectAttachments(tmpDir)).toStrictEqual([
@@ -28,9 +63,30 @@ test('collect shell script', async () => {
28
63
  path: join(tmpDir, 'init-cloud.sh'),
29
64
  content: 'whoopie',
30
65
  filename: 'init-cloud.sh',
66
+ source: 'whoopie',
67
+ type: 'x-shellscript',
68
+ },
69
+ ])
70
+ })
71
+
72
+ test('evals template expressions', async () => {
73
+ const resourceTmpDir = await makeTempDir()
74
+ await makeFile('whoopie', 'whoopie', resourceTmpDir)
75
+ await makeFile(
76
+ 'init-cloud.sh',
77
+ `\${{ file('${resourceTmpDir}/whoopie') }}`,
78
+ tmpDir,
79
+ )
80
+ expect(await collectAttachments(tmpDir)).toStrictEqual([
81
+ {
82
+ path: tmpDir + '/init-cloud.sh',
83
+ content: 'whoopie',
84
+ filename: 'init-cloud.sh',
85
+ source: `\${{ file('${resourceTmpDir}/whoopie') }}`,
31
86
  type: 'x-shellscript',
32
87
  },
33
88
  ])
89
+ await removeDir(resourceTmpDir)
34
90
  })
35
91
 
36
92
  test('sorts attachments by filename', async () => {
@@ -41,12 +97,14 @@ test('sorts attachments by filename', async () => {
41
97
  path: join(tmpDir, '01-init-cloud.sh'),
42
98
  content: 'whoopie',
43
99
  filename: '01-init-cloud.sh',
100
+ source: 'whoopie',
44
101
  type: 'x-shellscript',
45
102
  },
46
103
  {
47
104
  path: join(tmpDir, '02-init-cloud.sh'),
48
105
  content: 'whoopie',
49
106
  filename: '02-init-cloud.sh',
107
+ source: 'whoopie',
50
108
  type: 'x-shellscript',
51
109
  },
52
110
  ])
@@ -1,4 +1,5 @@
1
- import { readDirListing, readToString } from '#c2/fs.ts'
1
+ import { evalTemplateExpressions } from '#c2/expression'
2
+ import { readDirListing, readToString } from '#c2/fs'
2
3
 
3
4
  export type AttachmentType = 'cloud-config' | 'x-shellscript'
4
5
 
@@ -6,20 +7,24 @@ export interface Attachment {
6
7
  path: string
7
8
  content: string
8
9
  filename: string
10
+ source: string
9
11
  type: AttachmentType
10
12
  }
11
13
 
12
14
  export async function collectAttachments(
13
15
  dir: string,
14
16
  ): 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)
17
+ const filenames = await readDirListing(dir)
18
+ const attachments = await Promise.all(
19
+ filenames.map(async filename => {
20
+ const path = `${dir}/${filename}`
21
+ const source = await readToString(path)
22
+ const type = resolveAttachmentType(filename, source)
23
+ const content = await evalTemplateExpressions(source)
24
+ return { content, filename, path, type, source }
25
+ }),
26
+ )
27
+ return attachments.sort(compareAttachmentFilenames)
23
28
  }
24
29
 
25
30
  function compareAttachmentFilenames(
@@ -33,17 +38,18 @@ function compareAttachmentFilenames(
33
38
 
34
39
  export function resolveAttachmentType(
35
40
  filename: string,
36
- content: string,
41
+ source: string,
37
42
  ): AttachmentType {
38
- if (
39
- filename.endsWith('.yml') ||
40
- (filename.endsWith('.yaml') &&
41
- content.trim().startsWith('#cloud-config'))
42
- ) {
43
+ if (filename.endsWith('.yml') || filename.endsWith('.yaml')) {
44
+ if (!source.trim().startsWith('#cloud-config')) {
45
+ throw new Error(
46
+ 'YAML cloud config must start with a #cloud-config comment',
47
+ )
48
+ }
43
49
  return 'cloud-config'
44
50
  } else if (filename.endsWith('.sh')) {
45
51
  return 'x-shellscript'
46
52
  } else {
47
- throw new Error(`unsupported file type ${filename}`)
53
+ throw new Error(`${filename} is an unsupported file type`)
48
54
  }
49
55
  }
package/lib/build.spec.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, expect, test } from 'bun:test'
2
- import { buildUserData } from '#c2/build.ts'
3
- import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing.ts'
2
+ import { buildUserData } from '#c2/build'
3
+ import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing'
4
4
 
5
5
  let tmpDir: string
6
6
 
@@ -11,9 +11,9 @@ beforeEach(async () => {
11
11
  afterEach(async () => removeDir(tmpDir))
12
12
 
13
13
  test('build user data of single file', async () => {
14
- const initCloudYml = 'whoopie'
14
+ const initCloudYml = '#cloud-config\nwhoopie'
15
15
  await makeFile('init-cloud.yml', initCloudYml, tmpDir)
16
- expect(await buildUserData(tmpDir)).toStrictEqual('whoopie')
16
+ expect(await buildUserData(tmpDir)).toStrictEqual(initCloudYml)
17
17
  })
18
18
 
19
19
  test('build user data of single file with template expression', async () => {
@@ -21,38 +21,37 @@ test('build user data of single file with template expression', async () => {
21
21
  await makeFile('whoopie', 'whoopie', whoopie)
22
22
  await makeFile(
23
23
  'init-cloud.yml',
24
- `\${{ file('${whoopie}/whoopie')}}`,
24
+ `#cloud-config\n\${{ file('${whoopie}/whoopie')}}`,
25
25
  tmpDir,
26
26
  )
27
- expect(await buildUserData(tmpDir)).toStrictEqual('whoopie')
27
+ expect(await buildUserData(tmpDir)).toStrictEqual('#cloud-config\nwhoopie')
28
28
  await removeDir(whoopie)
29
29
  })
30
30
 
31
31
  test('build user data multipart message', async () => {
32
- await makeFile('1-init-cloud.yml', 'whoopie', tmpDir)
32
+ await makeFile('1-init-cloud.yml', '#cloud-config\nwhoopie', tmpDir)
33
33
  await makeFile('2-init-cloud.sh', 'cushion', tmpDir)
34
34
  const boundary = Bun.randomUUIDv7()
35
35
  expect(
36
36
  await buildUserData(tmpDir, { attachmentBoundary: boundary }),
37
37
  ).toStrictEqual(
38
- `Content-Type: multipart/mixed; boundary=${boundary}
39
- MIME-Version: 1.0
40
- Number-Attachments: 2
41
- --${boundary}
42
- Content-Type: text/cloud-config; charset="us-ascii"
43
- MIME-Version: 1.0
44
- Content-Transfer-Encoding: 7bit
45
- Content-Disposition: attachment; filename="1-init-cloud.yml"
46
-
47
- whoopie
48
- --${boundary}
49
- Content-Type: text/x-shellscript; charset="us-ascii"
50
- MIME-Version: 1.0
51
- Content-Transfer-Encoding: 7bit
52
- Content-Disposition: attachment; filename="2-init-cloud.sh"
53
-
54
- cushion
55
- --${boundary}
38
+ `Content-Type: multipart/mixed; boundary=${boundary}\r
39
+ MIME-Version: 1.0\r
40
+ Number-Attachments: 2\r
41
+ --${boundary}\r
42
+ Content-Type: text/cloud-config; charset="us-ascii"\r
43
+ Content-Transfer-Encoding: 7bit\r
44
+ Content-Disposition: attachment; filename="1-init-cloud.yml"\r
45
+ \r
46
+ #cloud-config
47
+ whoopie\r
48
+ --${boundary}\r
49
+ Content-Type: text/x-shellscript; charset="us-ascii"\r
50
+ Content-Transfer-Encoding: 7bit\r
51
+ Content-Disposition: attachment; filename="2-init-cloud.sh"\r
52
+ \r
53
+ cushion\r
54
+ --${boundary}\r
56
55
  `,
57
56
  )
58
57
  })
@@ -62,7 +61,7 @@ test('build user data multipart with template expression', async () => {
62
61
  await makeFile('whoopie', 'whoopie', whoopie)
63
62
  await makeFile(
64
63
  '1-init-cloud.yml',
65
- `\${{ file('${whoopie}/whoopie')}}`,
64
+ `#cloud-config\n\${{ file('${whoopie}/whoopie')}}`,
66
65
  tmpDir,
67
66
  )
68
67
  await makeFile('2-init-cloud.sh', 'cushion', tmpDir)
@@ -70,24 +69,22 @@ test('build user data multipart with template expression', async () => {
70
69
  expect(
71
70
  await buildUserData(tmpDir, { attachmentBoundary: boundary }),
72
71
  ).toStrictEqual(
73
- `Content-Type: multipart/mixed; boundary=${boundary}
74
- MIME-Version: 1.0
75
- Number-Attachments: 2
76
- --${boundary}
77
- Content-Type: text/cloud-config; charset="us-ascii"
78
- MIME-Version: 1.0
79
- Content-Transfer-Encoding: 7bit
80
- Content-Disposition: attachment; filename="1-init-cloud.yml"
81
-
82
- whoopie
83
- --${boundary}
84
- Content-Type: text/x-shellscript; charset="us-ascii"
85
- MIME-Version: 1.0
86
- Content-Transfer-Encoding: 7bit
87
- Content-Disposition: attachment; filename="2-init-cloud.sh"
88
-
89
- cushion
90
- --${boundary}
72
+ `Content-Type: multipart/mixed; boundary=${boundary}\r
73
+ MIME-Version: 1.0\r
74
+ Number-Attachments: 2\r
75
+ --${boundary}\r
76
+ Content-Type: text/cloud-config; charset="us-ascii"\r
77
+ Content-Transfer-Encoding: 7bit\r
78
+ Content-Disposition: attachment; filename="1-init-cloud.yml"\r
79
+ \r
80
+ #cloud-config\nwhoopie\r
81
+ --${boundary}\r
82
+ Content-Type: text/x-shellscript; charset="us-ascii"\r
83
+ Content-Transfer-Encoding: 7bit\r
84
+ Content-Disposition: attachment; filename="2-init-cloud.sh"\r
85
+ \r
86
+ cushion\r
87
+ --${boundary}\r
91
88
  `,
92
89
  )
93
90
  })
package/lib/build.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { type Attachment, collectAttachments } from '#c2/attachments.ts'
2
- import { evalTemplateExpressions } from '#c2/expression.ts'
3
- import { readToString } from '#c2/fs.ts'
1
+ import { collectAttachments } from '#c2/attachments'
2
+ import { MultipartMessage } from '#c2/http'
4
3
 
5
4
  export type BuildUserDataOpts = {
6
5
  attachmentBoundary?: string
@@ -15,45 +14,11 @@ export async function buildUserData(
15
14
  case 0:
16
15
  throw new Error(`nothing found in dir ${userDataDir}`)
17
16
  case 1:
18
- return evalTemplateExpressions(
19
- await readToString(attachments[0].path),
20
- )
17
+ return attachments[0].content
21
18
  default:
22
- return buildMultipartUserData(attachments, opts?.attachmentBoundary)
19
+ return new MultipartMessage(
20
+ attachments,
21
+ opts?.attachmentBoundary,
22
+ ).toHTTP()
23
23
  }
24
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/c2.api.ts CHANGED
@@ -1 +1 @@
1
- export { type BuildUserDataOpts, buildUserData } from '#c2/build.ts'
1
+ export { type BuildUserDataOpts, buildUserData } from '#c2/build'
package/lib/c2.bin.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { buildUserData } from '#c2/build.ts'
4
- import { parseArgs, type ParsedArgs } from '#c2/cli.ts'
5
- import { doesDirExist } from '#c2/fs.ts'
3
+ import { buildUserData } from '#c2/build'
4
+ import { parseArgs, type ParsedArgs } from '#c2/cli'
5
+ import { doesDirExist } from '#c2/fs'
6
+ import { startUserDataHttp } from '#c2/http'
6
7
 
7
8
  let args: ParsedArgs | undefined
8
9
  try {
@@ -21,17 +22,25 @@ if (!args || args.help) {
21
22
  )
22
23
  }
23
24
 
24
- if (args.httpPort) {
25
- errorExit('--http PORT is not yet implemented')
26
- }
27
-
28
25
  if (!(await doesDirExist(args.userDataDir))) {
29
26
  errorExit(`${args.userDataDir} directory does not exist`)
30
27
  }
31
28
 
29
+ let work: () => Promise<void>
30
+
31
+ if (typeof args.httpPort !== 'undefined') {
32
+ work = async () => {
33
+ startUserDataHttp(args.httpPort!, args.userDataDir)
34
+ }
35
+ } else {
36
+ work = async () => {
37
+ const userData = await buildUserData(args.userDataDir)
38
+ console.log(args.base64 ? btoa(userData) : userData)
39
+ }
40
+ }
41
+
32
42
  try {
33
- const userData = await buildUserData(args.userDataDir)
34
- console.log(args.base64 ? btoa(userData) : userData)
43
+ await work()
35
44
  } catch (e: any) {
36
45
  errorExit(e.message)
37
46
  }
package/lib/cli.spec.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { expect, test } from 'bun:test'
2
- import { parseArgs } from '#c2/cli.ts'
2
+ import { parseArgs } from '#c2/cli'
3
3
 
4
- test('parseArgs', () => {
4
+ test('parseArgs with ts entrypoint', () => {
5
5
  expect(
6
6
  parseArgs([
7
7
  '/Users/who/.bun/bin/bun',
8
- '/Users/who/user-data/c2.ts',
8
+ '/Users/who/user-data/lib/c2.bin.ts',
9
9
  'user_data_dir',
10
10
  ]),
11
11
  ).toStrictEqual({
@@ -14,9 +14,44 @@ test('parseArgs', () => {
14
14
  })
15
15
  })
16
16
 
17
+ test('parseArgs with executable entrypoint', () => {
18
+ expect(
19
+ parseArgs([
20
+ '/Users/who/.nvm/versions/node/v23.7.0/bin/node',
21
+ '/Users/who/.nvm/versions/node/v23.7.0/bin/c2',
22
+ 'user_data_dir',
23
+ ]),
24
+ ).toStrictEqual({
25
+ base64: false,
26
+ userDataDir: 'user_data_dir',
27
+ })
28
+ })
29
+
30
+ test('parseArgs with js entrypoint', () => {
31
+ expect(
32
+ parseArgs([
33
+ '/Users/who/.nvm/versions/node/v23.7.0/bin/node',
34
+ '/Users/who/user-data/lib_js/c2.bin.js',
35
+ 'user_data_dir',
36
+ ]),
37
+ ).toStrictEqual({
38
+ base64: false,
39
+ userDataDir: 'user_data_dir',
40
+ })
41
+ })
42
+
43
+ test('parseArgs with unexepected argv', () => {
44
+ expect(() => parseArgs(['tippity', 'doo', 'da'])).toThrow(
45
+ 'unexpected program installation\nplease report at https://github.com/eighty4/c2/issues/new and include:\n\n[\n \"tippity\",\n \"doo\",\n \"da\"\n]',
46
+ )
47
+ })
48
+
17
49
  test('parseArgs errors without USER_DATA_DIR', () => {
18
50
  expect(() =>
19
- parseArgs(['/Users/who/.bun/bin/bun', '/Users/who/user-data/c2.ts']),
51
+ parseArgs([
52
+ '/Users/who/.bun/bin/bun',
53
+ '/Users/who/user-data/lib/c2.bin.ts',
54
+ ]),
20
55
  ).toThrow()
21
56
  })
22
57
 
@@ -24,18 +59,18 @@ test('parseArgs errors with extra USER_DATA_DIR', () => {
24
59
  expect(() =>
25
60
  parseArgs([
26
61
  '/Users/who/.bun/bin/bun',
27
- '/Users/who/user-data/c2.ts',
62
+ '/Users/who/user-data/lib/c2.bin.ts',
28
63
  'user_data_dir',
29
64
  'some_other_arg',
30
65
  ]),
31
- ).toThrow('')
66
+ ).toThrow()
32
67
  })
33
68
 
34
69
  test('parseArgs with --base64', () => {
35
70
  expect(
36
71
  parseArgs([
37
72
  '/Users/who/.bun/bin/bun',
38
- '/Users/who/user-data/c2.ts',
73
+ '/Users/who/user-data/lib/c2.bin.ts',
39
74
  '--base64',
40
75
  'user_data_dir',
41
76
  ]),
@@ -49,7 +84,7 @@ test('parseArgs with --http PORT', () => {
49
84
  expect(
50
85
  parseArgs([
51
86
  '/Users/who/.bun/bin/bun',
52
- '/Users/who/user-data/c2.ts',
87
+ '/Users/who/user-data/lib/c2.bin.ts',
53
88
  '--http',
54
89
  '6666',
55
90
  'user_data_dir',
@@ -61,10 +96,21 @@ test('parseArgs with --http bunk', () => {
61
96
  expect(() =>
62
97
  parseArgs([
63
98
  '/Users/who/.bun/bin/bun',
64
- '/Users/who/user-data/c2.ts',
99
+ '/Users/who/user-data/lib/c2.bin.ts',
65
100
  '--http',
66
101
  'bunk',
67
102
  'user_data_dir',
68
103
  ]),
69
104
  ).toThrow('--http bunk is not a valid http port')
70
105
  })
106
+
107
+ test('parseArgs with --http without PORT', () => {
108
+ expect(() =>
109
+ parseArgs([
110
+ '/Users/who/.bun/bin/bun',
111
+ '/Users/who/user-data/lib/c2.bin.ts',
112
+ 'user_data_dir',
113
+ '--http',
114
+ ]),
115
+ ).toThrow('--http did not include a PORT')
116
+ })
package/lib/cli.ts CHANGED
@@ -7,17 +7,35 @@ export type ParsedArgs =
7
7
  userDataDir: string
8
8
  }
9
9
 
10
- export function parseArgs(args?: Array<string>): ParsedArgs {
11
- if (!args) {
12
- args = process.argv
10
+ const SCRIPT_SUFFIXES = Object.freeze([
11
+ '/c2',
12
+ '/lib_js/c2.bin.js',
13
+ '/lib/c2.bin.ts',
14
+ ])
15
+
16
+ export function parseArgs(input?: Array<string>): ParsedArgs {
17
+ if (!input) {
18
+ input = process.argv
19
+ }
20
+ let parsing: Array<string> = [...input]
21
+ let shifted: string | undefined
22
+ let found_cli: boolean = false
23
+ while ((shifted = parsing.shift())) {
24
+ if (SCRIPT_SUFFIXES.some(suffix => shifted!.endsWith(suffix))) {
25
+ found_cli = true
26
+ break
27
+ }
28
+ }
29
+ if (!found_cli) {
30
+ throw new Error(
31
+ `unexpected program installation\nplease report at https://github.com/eighty4/c2/issues/new and include:\n\n${JSON.stringify(input, null, 4)}`,
32
+ )
13
33
  }
14
- args = [...args]
15
- while (!args.shift()!.endsWith('/c2.ts')) {}
16
34
  let base64 = false
17
35
  let httpPort: number | undefined
18
36
  let userData: Array<string> = []
19
37
  let expectHttpPort = false
20
- for (const arg of args) {
38
+ for (const arg of parsing) {
21
39
  if (expectHttpPort) {
22
40
  expectHttpPort = false
23
41
  httpPort = parseInt(arg, 10)
@@ -34,6 +52,9 @@ export function parseArgs(args?: Array<string>): ParsedArgs {
34
52
  userData.push(arg)
35
53
  }
36
54
  }
55
+ if (expectHttpPort) {
56
+ throw new Error('--http did not include a PORT')
57
+ }
37
58
  switch (userData.length) {
38
59
  case 1:
39
60
  const userDataDir = userData[0]
@@ -1,7 +1,7 @@
1
1
  import { afterAll, afterEach, beforeEach, expect, test } from 'bun:test'
2
2
  import { join } from 'node:path'
3
- import { evalTemplateExpressions } from '#c2/expression.ts'
4
- import { makeFile, makeTempDir, removeDir } from '#c2/fs.testing.ts'
3
+ import { evalTemplateExpressions } from './expression.ts'
4
+ import { makeFile, makeTempDir, removeDir } from './fs.testing.ts'
5
5
 
6
6
  let files: Array<string> = []
7
7
  let tmpDir: string
@@ -32,6 +32,14 @@ test('env() not found', async () => {
32
32
  )
33
33
  })
34
34
 
35
+ test('env() name not valid', async () => {
36
+ ;['2_NOT_VALID', 'NOT=VALID', 'NOT@VALID'].forEach(envVar =>
37
+ expect(() =>
38
+ evalTemplateExpressions(`\${{ env('${envVar}') }}`),
39
+ ).toThrow(`env var \`${envVar}\` is not a valid name`),
40
+ )
41
+ })
42
+
35
43
  test('file() not found', async () => {
36
44
  const path = join(await makeTempDir(), 'whoopie')
37
45
  expect(() => evalTemplateExpressions(`\${{ file('${path}') }}`)).toThrow(
package/lib/expression.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import MagicString from 'magic-string'
2
- import { readToString } from '#c2/fs.ts'
2
+ import { readToString } from '#c2/fs'
3
3
 
4
4
  type TemplateExpression = {
5
5
  index: number
@@ -38,10 +38,8 @@ async function evaluate(expression: string): Promise<string> {
38
38
  let match: RegExpMatchArray | null
39
39
  if ((match = expression.match(/env\(\s*'(.*)'\s*\)/)) != null) {
40
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
- )
41
+ if (!/^[A-Z_][A-Z_\d]+$/.test(envVarKey)) {
42
+ throw new Error(`env var \`${envVarKey}\` is not a valid name`)
45
43
  }
46
44
  const envVarValue = process.env[envVarKey]
47
45
  if (!envVarValue) {
@@ -0,0 +1,57 @@
1
+ import { beforeEach, expect, test } from 'bun:test'
2
+ import { MultipartMessage, MultipartAttachment } from '#c2/http'
3
+
4
+ let message: MultipartMessage
5
+
6
+ beforeEach(() => {
7
+ message = new MultipartMessage(
8
+ [
9
+ {
10
+ path: 'upgrades',
11
+ content: '#cloud-config\nupgrades',
12
+ filename: 'upgrades.yml',
13
+ source: '#cloud-config\nupgrades',
14
+ type: 'cloud-config',
15
+ },
16
+ {
17
+ path: 'security',
18
+ content: 'security',
19
+ filename: 'security.sh',
20
+ source: 'security',
21
+ type: 'x-shellscript',
22
+ },
23
+ ],
24
+ 'BOUNDARY',
25
+ )
26
+ })
27
+
28
+ test('multipart message creates cloud-init payload', () => {
29
+ expect(message.toHTTP()).toStrictEqual(
30
+ `Content-Type: multipart/mixed; boundary=BOUNDARY\r
31
+ MIME-Version: 1.0\r
32
+ Number-Attachments: 2\r
33
+ --BOUNDARY\r
34
+ Content-Type: text/cloud-config; charset="us-ascii"\r
35
+ Content-Transfer-Encoding: 7bit\r
36
+ Content-Disposition: attachment; filename="upgrades.yml"\r
37
+ \r
38
+ #cloud-config
39
+ upgrades\r
40
+ --BOUNDARY\r
41
+ Content-Type: text/x-shellscript; charset="us-ascii"\r
42
+ Content-Transfer-Encoding: 7bit\r
43
+ Content-Disposition: attachment; filename="security.sh"\r
44
+ \r
45
+ security\r
46
+ --BOUNDARY\r
47
+ `,
48
+ )
49
+ })
50
+
51
+ test('multipart message creates HTTP headers', () => {
52
+ expect(message.headers).toStrictEqual({
53
+ 'Content-Type': 'multipart/mixed; boundary=BOUNDARY',
54
+ 'MIME-Version': '1.0',
55
+ 'Number-Attachments': '2',
56
+ })
57
+ })
package/lib/http.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { createServer, type ServerResponse } from 'node:http'
3
+ import { type Attachment, collectAttachments } from '#c2/attachments'
4
+
5
+ export function startUserDataHttp(port: number, userDataDir: string) {
6
+ const server = createServer((req, res) => {
7
+ console.log(req.method, req.url)
8
+ if (req.method !== 'GET') {
9
+ res.writeHead(405)
10
+ res.end()
11
+ } else if (req.url === '/user-data') {
12
+ sendUserData(res, userDataDir).then()
13
+ } else {
14
+ res.writeHead(
15
+ req.url === '/meta-data' || req.url === '/network-config'
16
+ ? 200
17
+ : 404,
18
+ )
19
+ res.end()
20
+ }
21
+ })
22
+ server.listen(port, () => {
23
+ console.log(userDataDir, `up @ http://localhost:${port}/user-data`)
24
+ })
25
+ }
26
+
27
+ async function sendUserData(res: ServerResponse, userDataDir: string) {
28
+ try {
29
+ const message = new MultipartMessage(
30
+ await collectAttachments(userDataDir),
31
+ )
32
+ res.writeHead(200)
33
+ res.write(message.toHTTP())
34
+ } catch (e: any) {
35
+ console.error(500, '/user-data', e.message)
36
+ res.writeHead(500)
37
+ } finally {
38
+ res.end()
39
+ }
40
+ }
41
+
42
+ export class MultipartMessage {
43
+ static createBoundary(): string {
44
+ return randomUUID()
45
+ }
46
+
47
+ readonly #attachments: Array<MultipartAttachment>
48
+ readonly #boundary: string
49
+
50
+ constructor(
51
+ attachments: Array<Attachment>,
52
+ boundary: string = MultipartMessage.createBoundary(),
53
+ ) {
54
+ this.#attachments = attachments.map(a => new MultipartAttachment(a))
55
+ this.#boundary = boundary
56
+ }
57
+
58
+ get attachments(): Array<MultipartAttachment> {
59
+ return this.#attachments
60
+ }
61
+
62
+ get boundary(): string {
63
+ return this.#boundary
64
+ }
65
+
66
+ get headers(): Record<string, string> {
67
+ return {
68
+ 'Content-Type': `multipart/mixed; boundary=${this.#boundary}`,
69
+ 'MIME-Version': '1.0',
70
+ 'Number-Attachments': `${this.#attachments.length}`,
71
+ }
72
+ }
73
+
74
+ toHTTP(): string {
75
+ return [
76
+ `Content-Type: multipart/mixed; boundary=${this.#boundary}`,
77
+ 'MIME-Version: 1.0',
78
+ `Number-Attachments: ${this.#attachments.length}`,
79
+ this.responseBody(),
80
+ ].join('\r\n')
81
+ }
82
+
83
+ responseBody(): string {
84
+ return [
85
+ '--' + this.#boundary,
86
+ this.#attachments
87
+ .map(a => a.toHTTP())
88
+ .join('\r\n--' + this.#boundary + '\r\n'),
89
+ '--' + this.#boundary + '\r\n',
90
+ ].join('\r\n')
91
+ }
92
+ }
93
+
94
+ export class MultipartAttachment {
95
+ #attachment: Attachment
96
+
97
+ constructor(attachment: Attachment) {
98
+ this.#attachment = attachment
99
+ }
100
+
101
+ toHTTP(): string {
102
+ return [
103
+ `Content-Type: text/${this.#attachment.type}; charset="us-ascii"`,
104
+ 'Content-Transfer-Encoding: 7bit',
105
+ `Content-Disposition: attachment; filename="${this.#attachment.filename}"`,
106
+ '',
107
+ this.#attachment.content,
108
+ ].join('\r\n')
109
+ }
110
+ }
@@ -1,13 +1,15 @@
1
- import { readDirListing, readToString } from '#c2/fs.ts';
1
+ import { evalTemplateExpressions } from '#c2/expression';
2
+ import { readDirListing, readToString } from '#c2/fs';
2
3
  export async function collectAttachments(dir) {
3
- const result = [];
4
- for (const filename of await readDirListing(dir)) {
4
+ const filenames = await readDirListing(dir);
5
+ const attachments = await Promise.all(filenames.map(async (filename) => {
5
6
  const path = `${dir}/${filename}`;
6
- const content = await readToString(path);
7
- const type = resolveAttachmentType(filename, content);
8
- result.push({ content, filename, path, type });
9
- }
10
- return result.sort(compareAttachmentFilenames);
7
+ const source = await readToString(path);
8
+ const type = resolveAttachmentType(filename, source);
9
+ const content = await evalTemplateExpressions(source);
10
+ return { content, filename, path, type, source };
11
+ }));
12
+ return attachments.sort(compareAttachmentFilenames);
11
13
  }
12
14
  function compareAttachmentFilenames(a1, a2) {
13
15
  if (a1.filename === a2.filename)
@@ -16,16 +18,17 @@ function compareAttachmentFilenames(a1, a2) {
16
18
  return 1;
17
19
  return -1;
18
20
  }
19
- export function resolveAttachmentType(filename, content) {
20
- if (filename.endsWith('.yml') ||
21
- (filename.endsWith('.yaml') &&
22
- content.trim().startsWith('#cloud-config'))) {
21
+ export function resolveAttachmentType(filename, source) {
22
+ if (filename.endsWith('.yml') || filename.endsWith('.yaml')) {
23
+ if (!source.trim().startsWith('#cloud-config')) {
24
+ throw new Error('YAML cloud config must start with a #cloud-config comment');
25
+ }
23
26
  return 'cloud-config';
24
27
  }
25
28
  else if (filename.endsWith('.sh')) {
26
29
  return 'x-shellscript';
27
30
  }
28
31
  else {
29
- throw new Error(`unsupported file type ${filename}`);
32
+ throw new Error(`${filename} is an unsupported file type`);
30
33
  }
31
34
  }
package/lib_js/build.js CHANGED
@@ -1,42 +1,13 @@
1
- import { collectAttachments } from '#c2/attachments.ts';
2
- import { evalTemplateExpressions } from '#c2/expression.ts';
3
- import { readToString } from '#c2/fs.ts';
1
+ import { collectAttachments } from '#c2/attachments';
2
+ import { MultipartMessage } from '#c2/http';
4
3
  export async function buildUserData(userDataDir, opts) {
5
4
  const attachments = await collectAttachments(userDataDir);
6
5
  switch (attachments.length) {
7
6
  case 0:
8
7
  throw new Error(`nothing found in dir ${userDataDir}`);
9
8
  case 1:
10
- return evalTemplateExpressions(await readToString(attachments[0].path));
9
+ return attachments[0].content;
11
10
  default:
12
- return buildMultipartUserData(attachments, opts?.attachmentBoundary);
11
+ return new MultipartMessage(attachments, opts?.attachmentBoundary).toHTTP();
13
12
  }
14
13
  }
15
- function createBoundary() {
16
- return new Date().toISOString();
17
- }
18
- async function buildMultipartUserData(attachments, boundary = createBoundary()) {
19
- let result = `Content-Type: multipart/mixed; boundary=${boundary}
20
- MIME-Version: 1.0
21
- Number-Attachments: ${attachments.length}
22
- --${boundary}
23
- `;
24
- for (const attachment of attachments) {
25
- let content;
26
- try {
27
- content = await evalTemplateExpressions(attachment.content);
28
- }
29
- catch (e) {
30
- throw new Error(`error templating ${attachment.filename}: ${e.message}`);
31
- }
32
- result += `Content-Type: text/${attachment.type}; charset="us-ascii"
33
- MIME-Version: 1.0
34
- Content-Transfer-Encoding: 7bit
35
- Content-Disposition: attachment; filename="${attachment.filename}"
36
-
37
- ${content}
38
- --${boundary}
39
- `;
40
- }
41
- return result;
42
- }
package/lib_js/c2.api.js CHANGED
@@ -1 +1 @@
1
- export { buildUserData } from '#c2/build.ts';
1
+ export { buildUserData } from '#c2/build';
package/lib_js/c2.bin.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { buildUserData } from '#c2/build.ts';
3
- import { parseArgs } from '#c2/cli.ts';
4
- import { doesDirExist } from '#c2/fs.ts';
2
+ import { buildUserData } from '#c2/build';
3
+ import { parseArgs } from '#c2/cli';
4
+ import { doesDirExist } from '#c2/fs';
5
+ import { startUserDataHttp } from '#c2/http';
5
6
  let args;
6
7
  try {
7
8
  args = parseArgs();
@@ -16,15 +17,23 @@ if (!args || args.help) {
16
17
  const required = (s) => `\u001b[1m${s}\u001b[0m`;
17
18
  errorExit(`c2 ${optional('[[--base64] | [--http PORT]]')} ${required('USER_DATA_DIR')}`);
18
19
  }
19
- if (args.httpPort) {
20
- errorExit('--http PORT is not yet implemented');
21
- }
22
20
  if (!(await doesDirExist(args.userDataDir))) {
23
21
  errorExit(`${args.userDataDir} directory does not exist`);
24
22
  }
23
+ let work;
24
+ if (typeof args.httpPort !== 'undefined') {
25
+ work = async () => {
26
+ startUserDataHttp(args.httpPort, args.userDataDir);
27
+ };
28
+ }
29
+ else {
30
+ work = async () => {
31
+ const userData = await buildUserData(args.userDataDir);
32
+ console.log(args.base64 ? btoa(userData) : userData);
33
+ };
34
+ }
25
35
  try {
26
- const userData = await buildUserData(args.userDataDir);
27
- console.log(args.base64 ? btoa(userData) : userData);
36
+ await work();
28
37
  }
29
38
  catch (e) {
30
39
  errorExit(e.message);
package/lib_js/cli.js CHANGED
@@ -1,14 +1,29 @@
1
- export function parseArgs(args) {
2
- if (!args) {
3
- args = process.argv;
1
+ const SCRIPT_SUFFIXES = Object.freeze([
2
+ '/c2',
3
+ '/lib_js/c2.bin.js',
4
+ '/lib/c2.bin.ts',
5
+ ]);
6
+ export function parseArgs(input) {
7
+ if (!input) {
8
+ input = process.argv;
9
+ }
10
+ let parsing = [...input];
11
+ let shifted;
12
+ let found_cli = false;
13
+ while ((shifted = parsing.shift())) {
14
+ if (SCRIPT_SUFFIXES.some(suffix => shifted.endsWith(suffix))) {
15
+ found_cli = true;
16
+ break;
17
+ }
18
+ }
19
+ if (!found_cli) {
20
+ throw new Error(`unexpected program installation\nplease report at https://github.com/eighty4/c2/issues/new and include:\n\n${JSON.stringify(input, null, 4)}`);
4
21
  }
5
- args = [...args];
6
- while (!args.shift().endsWith('/c2.ts')) { }
7
22
  let base64 = false;
8
23
  let httpPort;
9
24
  let userData = [];
10
25
  let expectHttpPort = false;
11
- for (const arg of args) {
26
+ for (const arg of parsing) {
12
27
  if (expectHttpPort) {
13
28
  expectHttpPort = false;
14
29
  httpPort = parseInt(arg, 10);
@@ -29,6 +44,9 @@ export function parseArgs(args) {
29
44
  userData.push(arg);
30
45
  }
31
46
  }
47
+ if (expectHttpPort) {
48
+ throw new Error('--http did not include a PORT');
49
+ }
32
50
  switch (userData.length) {
33
51
  case 1:
34
52
  const userDataDir = userData[0];
@@ -1,5 +1,5 @@
1
1
  import MagicString from 'magic-string';
2
- import { readToString } from '#c2/fs.ts';
2
+ import { readToString } from '#c2/fs';
3
3
  export async function evalTemplateExpressions(content) {
4
4
  const regex = new RegExp(/\${{\s*(.*)\s*}}/g);
5
5
  let match;
@@ -24,8 +24,8 @@ async function evaluate(expression) {
24
24
  let match;
25
25
  if ((match = expression.match(/env\(\s*'(.*)'\s*\)/)) != null) {
26
26
  const envVarKey = match[1];
27
- if (!/[A-Z_]+/.test(envVarKey)) {
28
- throw new Error(`env var expression \`${envVarKey}\` is not valid syntax`);
27
+ if (!/^[A-Z_][A-Z_\d]+$/.test(envVarKey)) {
28
+ throw new Error(`env var \`${envVarKey}\` is not a valid name`);
29
29
  }
30
30
  const envVarValue = process.env[envVarKey];
31
31
  if (!envVarValue) {
package/lib_js/http.js ADDED
@@ -0,0 +1,94 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { collectAttachments } from '#c2/attachments';
4
+ export function startUserDataHttp(port, userDataDir) {
5
+ const server = createServer((req, res) => {
6
+ console.log(req.method, req.url);
7
+ if (req.method !== 'GET') {
8
+ res.writeHead(405);
9
+ res.end();
10
+ }
11
+ else if (req.url === '/user-data') {
12
+ sendUserData(res, userDataDir).then();
13
+ }
14
+ else {
15
+ res.writeHead(req.url === '/meta-data' || req.url === '/network-config'
16
+ ? 200
17
+ : 404);
18
+ res.end();
19
+ }
20
+ });
21
+ server.listen(port, () => {
22
+ console.log(userDataDir, `up @ http://localhost:${port}/user-data`);
23
+ });
24
+ }
25
+ async function sendUserData(res, userDataDir) {
26
+ try {
27
+ const message = new MultipartMessage(await collectAttachments(userDataDir));
28
+ res.writeHead(200);
29
+ res.write(message.toHTTP());
30
+ }
31
+ catch (e) {
32
+ console.error(500, '/user-data', e.message);
33
+ res.writeHead(500);
34
+ }
35
+ finally {
36
+ res.end();
37
+ }
38
+ }
39
+ export class MultipartMessage {
40
+ static createBoundary() {
41
+ return randomUUID();
42
+ }
43
+ #attachments;
44
+ #boundary;
45
+ constructor(attachments, boundary = MultipartMessage.createBoundary()) {
46
+ this.#attachments = attachments.map(a => new MultipartAttachment(a));
47
+ this.#boundary = boundary;
48
+ }
49
+ get attachments() {
50
+ return this.#attachments;
51
+ }
52
+ get boundary() {
53
+ return this.#boundary;
54
+ }
55
+ get headers() {
56
+ return {
57
+ 'Content-Type': `multipart/mixed; boundary=${this.#boundary}`,
58
+ 'MIME-Version': '1.0',
59
+ 'Number-Attachments': `${this.#attachments.length}`,
60
+ };
61
+ }
62
+ toHTTP() {
63
+ return [
64
+ `Content-Type: multipart/mixed; boundary=${this.#boundary}`,
65
+ 'MIME-Version: 1.0',
66
+ `Number-Attachments: ${this.#attachments.length}`,
67
+ this.responseBody(),
68
+ ].join('\r\n');
69
+ }
70
+ responseBody() {
71
+ return [
72
+ '--' + this.#boundary,
73
+ this.#attachments
74
+ .map(a => a.toHTTP())
75
+ .join('\r\n--' + this.#boundary + '\r\n'),
76
+ '--' + this.#boundary + '\r\n',
77
+ ].join('\r\n');
78
+ }
79
+ }
80
+ export class MultipartAttachment {
81
+ #attachment;
82
+ constructor(attachment) {
83
+ this.#attachment = attachment;
84
+ }
85
+ toHTTP() {
86
+ return [
87
+ `Content-Type: text/${this.#attachment.type}; charset="us-ascii"`,
88
+ 'Content-Transfer-Encoding: 7bit',
89
+ `Content-Disposition: attachment; filename="${this.#attachment.filename}"`,
90
+ '',
91
+ this.#attachment.content,
92
+ ].join('\r\n');
93
+ }
94
+ }
@@ -3,8 +3,9 @@ export interface Attachment {
3
3
  path: string;
4
4
  content: string;
5
5
  filename: string;
6
+ source: string;
6
7
  type: AttachmentType;
7
8
  }
8
9
  export declare function collectAttachments(dir: string): Promise<Array<Attachment>>;
9
- export declare function resolveAttachmentType(filename: string, content: string): AttachmentType;
10
+ export declare function resolveAttachmentType(filename: string, source: string): AttachmentType;
10
11
  //# sourceMappingURL=attachments.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../lib/attachments.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,cAAc,GAAG,cAAc,GAAG,eAAe,CAAA;AAE7D,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,cAAc,CAAA;CACvB;AAED,wBAAsB,kBAAkB,CACpC,GAAG,EAAE,MAAM,GACZ,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAS5B;AAWD,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GAChB,cAAc,CAYhB"}
1
+ {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../lib/attachments.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GAAG,cAAc,GAAG,eAAe,CAAA;AAE7D,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,cAAc,CAAA;CACvB;AAED,wBAAsB,kBAAkB,CACpC,GAAG,EAAE,MAAM,GACZ,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAY5B;AAWD,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACf,cAAc,CAahB"}
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../lib/build.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,iBAAiB,GAAG;IAC5B,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC9B,CAAA;AAED,wBAAsB,aAAa,CAC/B,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE,iBAAiB,GACzB,OAAO,CAAC,MAAM,CAAC,CAYjB"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../lib/build.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,iBAAiB,GAAG;IAC5B,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC9B,CAAA;AAED,wBAAsB,aAAa,CAC/B,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE,iBAAiB,GACzB,OAAO,CAAC,MAAM,CAAC,CAajB"}
@@ -1,2 +1,2 @@
1
- export { type BuildUserDataOpts, buildUserData } from '#c2/build.ts';
1
+ export { type BuildUserDataOpts, buildUserData } from '#c2/build';
2
2
  //# sourceMappingURL=c2.api.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"c2.api.d.ts","sourceRoot":"","sources":["../lib/c2.api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,iBAAiB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"c2.api.d.ts","sourceRoot":"","sources":["../lib/c2.api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAA"}
@@ -6,5 +6,5 @@ export type ParsedArgs = {
6
6
  httpPort?: number;
7
7
  userDataDir: string;
8
8
  };
9
- export declare function parseArgs(args?: Array<string>): ParsedArgs;
9
+ export declare function parseArgs(input?: Array<string>): ParsedArgs;
10
10
  //# sourceMappingURL=cli.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../lib/cli.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAChB;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IACI,IAAI,CAAC,EAAE,KAAK,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACtB,CAAA;AAEP,wBAAgB,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,UAAU,CAsC1D"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../lib/cli.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAChB;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IACI,IAAI,CAAC,EAAE,KAAK,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACtB,CAAA;AAQP,wBAAgB,SAAS,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,UAAU,CAqD3D"}
@@ -0,0 +1,18 @@
1
+ import { type Attachment } from '#c2/attachments';
2
+ export declare function startUserDataHttp(port: number, userDataDir: string): void;
3
+ export declare class MultipartMessage {
4
+ #private;
5
+ static createBoundary(): string;
6
+ constructor(attachments: Array<Attachment>, boundary?: string);
7
+ get attachments(): Array<MultipartAttachment>;
8
+ get boundary(): string;
9
+ get headers(): Record<string, string>;
10
+ toHTTP(): string;
11
+ responseBody(): string;
12
+ }
13
+ export declare class MultipartAttachment {
14
+ #private;
15
+ constructor(attachment: Attachment);
16
+ toHTTP(): string;
17
+ }
18
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../lib/http.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAsB,MAAM,iBAAiB,CAAA;AAErE,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,QAoBlE;AAiBD,qBAAa,gBAAgB;;IACzB,MAAM,CAAC,cAAc,IAAI,MAAM;gBAQ3B,WAAW,EAAE,KAAK,CAAC,UAAU,CAAC,EAC9B,QAAQ,GAAE,MAA0C;IAMxD,IAAI,WAAW,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAE5C;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,IAAI,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMpC;IAED,MAAM,IAAI,MAAM;IAShB,YAAY,IAAI,MAAM;CASzB;AAED,qBAAa,mBAAmB;;gBAGhB,UAAU,EAAE,UAAU;IAIlC,MAAM,IAAI,MAAM;CASnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eighty4/c2",
3
- "version": "0.0.2-9",
3
+ "version": "0.0.2",
4
4
  "author": "Adam McKee <adam.be.g84d@gmail.com>",
5
5
  "repository": "https://github.com/eighty4/c2",
6
6
  "homepage": "https://github.com/eighty4/c2",
@@ -22,15 +22,24 @@
22
22
  "bin": {
23
23
  "c2": "./lib_js/c2.bin.js"
24
24
  },
25
- "main": "./lib_js/c2.api.js",
25
+ "exports": {
26
+ ".": {
27
+ "bun": "./lib/c2.api.ts",
28
+ "node": "./lib_js/c2.api.js"
29
+ }
30
+ },
26
31
  "imports": {
27
- "#c2/*.js": "./lib_js/*.js",
28
- "#c2/*.ts": "./lib/*.ts"
32
+ "#c2/*": {
33
+ "bun": "./lib/*.ts",
34
+ "node": "./lib_js/*.js"
35
+ }
29
36
  },
30
37
  "types": "./lib_types",
31
38
  "scripts": {
32
39
  "build": "tsc",
40
+ "fmt": "prettier --write .",
33
41
  "fmtcheck": "prettier --check .",
42
+ "test": "bun test",
34
43
  "typecheck": "tsc --noEmit"
35
44
  },
36
45
  "dependencies": {