@eighty4/c2 0.0.2-25 → 0.0.2-26

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.
@@ -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 './attachments.ts'
4
- import { makeFile, makeTempDir, removeDir } from './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,40 @@ beforeEach(async () => (tmpDir = await makeTempDir()))
9
9
 
10
10
  afterEach(async () => await removeDir(tmpDir))
11
11
 
12
+ test('collect throws error when yml does not have #cloud-config comment', 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 throws error when yml does not have #cloud-config comment', async () => {
33
+ await makeFile('init-cloud.yml', 'whoopie', tmpDir)
34
+ await expect(() => collectAttachments(tmpDir)).toThrow(
35
+ 'YAML cloud config must start with a #cloud-config comment',
36
+ )
37
+ })
38
+
39
+ test('collect throws error when yaml does not have #cloud-config comment', async () => {
40
+ await makeFile('init-cloud.yaml', 'whoopie', tmpDir)
41
+ await expect(() => collectAttachments(tmpDir)).toThrow(
42
+ 'YAML cloud config must start with a #cloud-config comment',
43
+ )
44
+ })
45
+
24
46
  test('collect shell script', async () => {
25
47
  await makeFile('init-cloud.sh', 'whoopie', tmpDir)
26
48
  expect(await collectAttachments(tmpDir)).toStrictEqual([
@@ -28,9 +50,30 @@ test('collect shell script', async () => {
28
50
  path: join(tmpDir, 'init-cloud.sh'),
29
51
  content: 'whoopie',
30
52
  filename: 'init-cloud.sh',
53
+ source: 'whoopie',
54
+ type: 'x-shellscript',
55
+ },
56
+ ])
57
+ })
58
+
59
+ test('evals template expressions', async () => {
60
+ const resourceTmpDir = await makeTempDir()
61
+ await makeFile('whoopie', 'whoopie', resourceTmpDir)
62
+ await makeFile(
63
+ 'init-cloud.sh',
64
+ `\${{ file('${resourceTmpDir}/whoopie') }}`,
65
+ tmpDir,
66
+ )
67
+ expect(await collectAttachments(tmpDir)).toStrictEqual([
68
+ {
69
+ path: tmpDir + '/init-cloud.sh',
70
+ content: 'whoopie',
71
+ filename: 'init-cloud.sh',
72
+ source: `\${{ file('${resourceTmpDir}/whoopie') }}`,
31
73
  type: 'x-shellscript',
32
74
  },
33
75
  ])
76
+ await removeDir(resourceTmpDir)
34
77
  })
35
78
 
36
79
  test('sorts attachments by filename', async () => {
@@ -41,12 +84,14 @@ test('sorts attachments by filename', async () => {
41
84
  path: join(tmpDir, '01-init-cloud.sh'),
42
85
  content: 'whoopie',
43
86
  filename: '01-init-cloud.sh',
87
+ source: 'whoopie',
44
88
  type: 'x-shellscript',
45
89
  },
46
90
  {
47
91
  path: join(tmpDir, '02-init-cloud.sh'),
48
92
  content: 'whoopie',
49
93
  filename: '02-init-cloud.sh',
94
+ source: 'whoopie',
50
95
  type: 'x-shellscript',
51
96
  },
52
97
  ])
@@ -1,4 +1,5 @@
1
- import { readDirListing, readToString } from './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 './build.ts'
3
- import { makeFile, makeTempDir, removeDir } from './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 './attachments.ts'
2
- import { evalTemplateExpressions } from './expression.ts'
3
- import { readToString } from './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 './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 './build.ts'
4
- import { parseArgs, type ParsedArgs } from './cli.ts'
5
- import { doesDirExist } from './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,24 @@
1
1
  import { expect, test } from 'bun:test'
2
- import { parseArgs } from './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.bin.ts',
8
+ '/Users/who/user-data/lib/c2.bin.ts',
9
+ 'user_data_dir',
10
+ ]),
11
+ ).toStrictEqual({
12
+ base64: false,
13
+ userDataDir: 'user_data_dir',
14
+ })
15
+ })
16
+
17
+ test('parseArgs with js entrypoint', () => {
18
+ expect(
19
+ parseArgs([
20
+ '/Users/who/.nvm/versions/node/v23.7.0/bin/node',
21
+ '/Users/who/user-data/lib_js/c2.bin.js',
9
22
  'user_data_dir',
10
23
  ]),
11
24
  ).toStrictEqual({
@@ -18,7 +31,7 @@ test('parseArgs errors without USER_DATA_DIR', () => {
18
31
  expect(() =>
19
32
  parseArgs([
20
33
  '/Users/who/.bun/bin/bun',
21
- '/Users/who/user-data/c2.bin.ts',
34
+ '/Users/who/user-data/lib/c2.bin.ts',
22
35
  ]),
23
36
  ).toThrow()
24
37
  })
@@ -27,7 +40,7 @@ test('parseArgs errors with extra USER_DATA_DIR', () => {
27
40
  expect(() =>
28
41
  parseArgs([
29
42
  '/Users/who/.bun/bin/bun',
30
- '/Users/who/user-data/c2.bin.ts',
43
+ '/Users/who/user-data/lib/c2.bin.ts',
31
44
  'user_data_dir',
32
45
  'some_other_arg',
33
46
  ]),
@@ -38,7 +51,7 @@ test('parseArgs with --base64', () => {
38
51
  expect(
39
52
  parseArgs([
40
53
  '/Users/who/.bun/bin/bun',
41
- '/Users/who/user-data/c2.bin.ts',
54
+ '/Users/who/user-data/lib/c2.bin.ts',
42
55
  '--base64',
43
56
  'user_data_dir',
44
57
  ]),
@@ -52,7 +65,7 @@ test('parseArgs with --http PORT', () => {
52
65
  expect(
53
66
  parseArgs([
54
67
  '/Users/who/.bun/bin/bun',
55
- '/Users/who/user-data/c2.bin.ts',
68
+ '/Users/who/user-data/lib/c2.bin.ts',
56
69
  '--http',
57
70
  '6666',
58
71
  'user_data_dir',
@@ -64,10 +77,21 @@ test('parseArgs with --http bunk', () => {
64
77
  expect(() =>
65
78
  parseArgs([
66
79
  '/Users/who/.bun/bin/bun',
67
- '/Users/who/user-data/c2.bin.ts',
80
+ '/Users/who/user-data/lib/c2.bin.ts',
68
81
  '--http',
69
82
  'bunk',
70
83
  'user_data_dir',
71
84
  ]),
72
85
  ).toThrow('--http bunk is not a valid http port')
73
86
  })
87
+
88
+ test('parseArgs with --http without PORT', () => {
89
+ expect(() =>
90
+ parseArgs([
91
+ '/Users/who/.bun/bin/bun',
92
+ '/Users/who/user-data/lib/c2.bin.ts',
93
+ 'user_data_dir',
94
+ '--http',
95
+ ]),
96
+ ).toThrow('--http did not include a PORT')
97
+ })
package/lib/cli.ts CHANGED
@@ -14,7 +14,10 @@ export function parseArgs(args?: Array<string>): ParsedArgs {
14
14
  args = [...args]
15
15
  let shifted
16
16
  while ((shifted = args.shift())) {
17
- if (shifted.endsWith('/c2.bin.js') || shifted.endsWith('/c2.bin.ts')) {
17
+ if (
18
+ shifted.endsWith('/lib_js/c2.bin.js') ||
19
+ shifted.endsWith('/lib/c2.bin.ts')
20
+ ) {
18
21
  break
19
22
  }
20
23
  }
@@ -39,6 +42,9 @@ export function parseArgs(args?: Array<string>): ParsedArgs {
39
42
  userData.push(arg)
40
43
  }
41
44
  }
45
+ if (expectHttpPort) {
46
+ throw new Error('--http did not include a PORT')
47
+ }
42
48
  switch (userData.length) {
43
49
  case 1:
44
50
  const userDataDir = userData[0]
package/lib/expression.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import MagicString from 'magic-string'
2
- import { readToString } from './fs.ts'
2
+ import { readToString } from '#c2/fs'
3
3
 
4
4
  type TemplateExpression = {
5
5
  index: number
@@ -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, message.headers)
33
+ res.write(message.responseBody())
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 "./fs.js";
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 "./attachments.js";
2
- import { evalTemplateExpressions } from "./expression.js";
3
- import { readToString } from "./fs.js";
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 "./build.js";
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 "./build.js";
3
- import { parseArgs } from "./cli.js";
4
- import { doesDirExist } from "./fs.js";
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
@@ -5,7 +5,8 @@ export function parseArgs(args) {
5
5
  args = [...args];
6
6
  let shifted;
7
7
  while ((shifted = args.shift())) {
8
- if (shifted.endsWith('/c2.bin.js') || shifted.endsWith('/c2.bin.ts')) {
8
+ if (shifted.endsWith('/lib_js/c2.bin.js') ||
9
+ shifted.endsWith('/lib/c2.bin.ts')) {
9
10
  break;
10
11
  }
11
12
  }
@@ -34,6 +35,9 @@ export function parseArgs(args) {
34
35
  userData.push(arg);
35
36
  }
36
37
  }
38
+ if (expectHttpPort) {
39
+ throw new Error('--http did not include a PORT');
40
+ }
37
41
  switch (userData.length) {
38
42
  case 1:
39
43
  const userDataDir = userData[0];
@@ -1,5 +1,5 @@
1
1
  import MagicString from 'magic-string';
2
- import { readToString } from "./fs.js";
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;
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, message.headers);
29
+ res.write(message.responseBody());
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 './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,YAAY,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"}
@@ -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,CA2C1D"}
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,CAiD1D"}
@@ -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-25",
3
+ "version": "0.0.2-26",
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,10 +22,22 @@
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_js/c2.api.js",
28
+ "node": "./lib/c2.api.ts"
29
+ }
30
+ },
31
+ "imports": {
32
+ "#c2/*": {
33
+ "bun": "./lib/*.ts",
34
+ "node": "./lib_js/*.js"
35
+ }
36
+ },
26
37
  "types": "./lib_types",
27
38
  "scripts": {
28
- "build": "tsc",
39
+ "build": "tsc && chmod +x lib_js/c2.bin.js",
40
+ "fmt": "prettier --write .",
29
41
  "fmtcheck": "prettier --check .",
30
42
  "typecheck": "tsc --noEmit"
31
43
  },