@makefully/adaptfully 4.1.0 → 4.2.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 CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 4.2.1 — 2026-09-09
6
+
7
+ ### Fixed
8
+
9
+ - Yap status poll logs only when the status message changes (same as Dutifully), instead of repeating every poll interval.
10
+ - Clearer errors when a chore download/decrypt/unpack fails (helps diagnose truncated Showfully responses).
11
+
12
+ ## 4.2.0 — 2026-09-08
13
+
14
+ ### Changed
15
+
16
+ - **Build / deploy / release** submit **Showfully Yap** chores (`POST /yap/wrapfully`) instead of direct Wrapfully HTTP. Requires a Showfully PAT (`SHOWFULLY_PAT` / `wrapfully.json` `accessToken`).
17
+ - Server URL defaults to Showfully (`https://make.makefullystudios.com/`); `wrapfully.json` may set `showfullyServer` or `server`.
18
+
19
+ ### Added
20
+
21
+ - Optional `--encrypt` / `"encrypt": true` chore envelope encryption (default Wrapfully fleet public key; override with `--encrypt-public-key`).
22
+ - `wrapfully.json` routing fields appended to Yap zips (`stage`, `route`, `platformKey`, `gameId`, `deploymentKey`).
23
+
5
24
  ## 4.1.0 — 2026-09-04
6
25
 
7
26
  ### Added
package/README.md CHANGED
@@ -21,29 +21,34 @@ Games register platform services before load and retrieve them in-game. Adaptful
21
21
  ### Pipeline stages
22
22
 
23
23
  ```bash
24
- adaptfully prebuild web # deploy/ output/web-prebuild/
25
- adaptfully build steam # prebuild + zip and send to Wrapfully (default deployment)
26
- adaptfully deploy web # prebuild + POST to Wrapfully for each of web's deployments
27
- adaptfully deploy web --deployment web-prod # deploy to a single named deployment
28
- adaptfully deploy steam # prebuild + POST to Wrapfully steam builder
24
+ # wrapfully.json (gitignored) or env — Showfully PAT required for build/deploy/release:
25
+ # { "accessToken": "sfpat_…", "server": "https://make.makefullystudios.com/" }
26
+ # optional: "encrypt": true
27
+
28
+ export SHOWFULLY_PAT=sfpat_…
29
+ adaptfully prebuild web # deploy/ → output/web-prebuild/ (local only)
30
+ adaptfully build steam # Yap /yap/wrapfully chore → artifact zip
31
+ adaptfully deploy web --deployment web-prod # Yap deploy chore
32
+ adaptfully release android --encrypt # optional envelope encryption
29
33
  adaptfully steam-publish # one-time: log in with steamcmd → assets/meta/deployments/steam/steam.json
30
- adaptfully google-publish --from ./sa.json # one-time: import Play service account → deployments/android/google.json
31
- adaptfully apple-publish # one-time: write App Store Connect creds → deployments/ios/apple.json
32
- adaptfully android-keystore # one-time: generate keystores + android build.json
33
- adaptfully apple-signing # one-time: CSR / .p12 / provisioning files under ios/apple/
34
34
  ```
35
35
 
36
36
  | Stage | What it does |
37
37
  |-------|----------------|
38
38
  | `prebuild` | Copy `deploy/` to `output/<platform>-prebuild/` and inject registrations into `config.htmlInjections` |
39
- | `build` | Prebuild, then POST the result to Wrapfully (artifact zip only) |
40
- | `deploy` | POST the prior build artifact from `./output/` to each configured deployment (or `--deployment`) |
39
+ | `build` | Prebuild, then submit a **Showfully Yap** `wrapfully` chore (PAT required); poll until the artifact zip returns |
40
+ | `deploy` | Submit a Yap deploy chore with the prior build artifact + deployment credentials |
41
+ | `release` | Yap release chore (build + configured deployments on Wrapfully) |
41
42
  | `steam-publish` | One-time local setup: install steamcmd, interactive login, write the `steam` deployment's `steam.json` (also updates `.gitignore`) |
42
43
  | `google-publish` | One-time local setup: import a Play Console service-account JSON into the `android` deployment folder (also updates `.gitignore`) |
43
44
  | `apple-publish` | One-time local setup: write App Store Connect credentials into the `ios` deployment folder (also updates `.gitignore`) |
44
45
  | `android-keystore` | One-time local setup: generate debug/release keystores and write `android` `build.json` (also updates `.gitignore`) |
45
46
  | `apple-signing` | One-time local setup: CSR → Apple `.cer` → `.p12` / provisioning profiles under `ios/apple/` (also updates `.gitignore`) |
46
47
 
48
+ **Server** means the Showfully Yap base (`SHOWFULLY_SERVER` / `wrapfully.json` `showfullyServer` or `server`), default `https://make.makefullystudios.com/`. Wrapfully workers claim chores with a separate service token.
49
+
50
+ **Encryption (optional):** `--encrypt` or `"encrypt": true` wraps the chore to the Makefully Wrapfully fleet public key (override with `--encrypt-public-key`). Results are decrypted locally with an ephemeral `resultPriv` kept only for that chore.
51
+
47
52
  First-time walkthroughs (manual steps + helper skip markers): [docs/credentials/](docs/credentials/README.md).
48
53
 
49
54
  `wrapfully-deploy` is a compatibility alias for `adaptfully deploy` when invoked with a Wrapfully builder name (`steam`, `win`, `android`, etc.).
@@ -0,0 +1,197 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import {Readable, PassThrough} from 'node:stream';
6
+ import {pipeline} from 'node:stream/promises';
7
+ import archiver from 'archiver';
8
+ import unzipper from 'unzip-stream';
9
+ import {
10
+ WRAPFULLY_DEFAULT_KID,
11
+ WRAPFULLY_DEFAULT_PUBLIC_KEY,
12
+ } from './wrapfullyDefaultPublicKey.js';
13
+
14
+ const
15
+ ENVELOPE_DATA = 'wrapfully.enc',
16
+ ENVELOPE_KEY = 'wrapfully.key.enc',
17
+ ENVELOPE_META = 'wrapfully-crypto.json',
18
+ RESULT_DATA = 'result.enc',
19
+ RESULT_KEY = 'result.key.enc',
20
+ ALG = 'RSA-OAEP-SHA256+AES-256-GCM';
21
+
22
+ function aesEncrypt (plaintext) {
23
+ const key = crypto.randomBytes(32);
24
+ const iv = crypto.randomBytes(12);
25
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
26
+ const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
27
+ const tag = cipher.getAuthTag();
28
+
29
+ return {key, ciphertext: Buffer.concat([iv, tag, enc])};
30
+ }
31
+
32
+ function aesDecrypt (key, blob) {
33
+ const iv = blob.subarray(0, 12);
34
+ const tag = blob.subarray(12, 28);
35
+ const data = blob.subarray(28);
36
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
37
+
38
+ decipher.setAuthTag(tag);
39
+
40
+ return Buffer.concat([decipher.update(data), decipher.final()]);
41
+ }
42
+
43
+ function wrapKey (aesKey, publicKeyPem) {
44
+ return crypto.publicEncrypt(
45
+ {
46
+ key: publicKeyPem,
47
+ padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
48
+ oaepHash: 'sha256',
49
+ },
50
+ aesKey,
51
+ );
52
+ }
53
+
54
+ function unwrapKey (wrapped, privateKeyPem) {
55
+ return crypto.privateDecrypt(
56
+ {
57
+ key: privateKeyPem,
58
+ padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
59
+ oaepHash: 'sha256',
60
+ },
61
+ wrapped,
62
+ );
63
+ }
64
+
65
+ export function generateResultKeyPair () {
66
+ const {publicKey, privateKey} = crypto.generateKeyPairSync('rsa', {
67
+ modulusLength: 2048,
68
+ publicKeyEncoding: {type: 'spki', format: 'pem'},
69
+ privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
70
+ });
71
+
72
+ return {publicKey, privateKey};
73
+ }
74
+
75
+ export function resolveEncryptPublicKey (wrapfullyConfig = {}, cliPublicKey) {
76
+ if (cliPublicKey) {
77
+ if (cliPublicKey.includes('BEGIN PUBLIC KEY')) {
78
+ return {pem: cliPublicKey.trim(), kid: wrapfullyConfig.encryptKid || 'override'};
79
+ }
80
+ return {
81
+ pem: fs.readFileSync(cliPublicKey, 'utf8').trim(),
82
+ kid: wrapfullyConfig.encryptKid || 'override',
83
+ };
84
+ }
85
+ if (wrapfullyConfig.encryptPublicKey) {
86
+ const value = wrapfullyConfig.encryptPublicKey;
87
+
88
+ if (String(value).includes('BEGIN PUBLIC KEY')) {
89
+ return {pem: String(value).trim(), kid: wrapfullyConfig.encryptKid || 'override'};
90
+ }
91
+ return {
92
+ pem: fs.readFileSync(value, 'utf8').trim(),
93
+ kid: wrapfullyConfig.encryptKid || 'override',
94
+ };
95
+ }
96
+
97
+ return {pem: WRAPFULLY_DEFAULT_PUBLIC_KEY.trim(), kid: WRAPFULLY_DEFAULT_KID};
98
+ }
99
+
100
+ async function bufferFromStream (stream) {
101
+ const chunks = [];
102
+
103
+ for await (const chunk of stream) {
104
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
105
+ }
106
+
107
+ return Buffer.concat(chunks);
108
+ }
109
+
110
+ async function zipToBuffer (appendFn) {
111
+ const archive = archiver('zip', {zlib: {level: 0}});
112
+ const pass = new PassThrough();
113
+ const chunks = [];
114
+
115
+ archive.pipe(pass);
116
+ pass.on('data', (c) => chunks.push(c));
117
+
118
+ const done = new Promise((resolve, reject) => {
119
+ pass.on('end', resolve);
120
+ pass.on('error', reject);
121
+ archive.on('error', reject);
122
+ });
123
+
124
+ await appendFn(archive);
125
+ await archive.finalize();
126
+ await done;
127
+
128
+ return Buffer.concat(chunks);
129
+ }
130
+
131
+ /**
132
+ * Wrap an inner zip stream/buffer in a Yap-safe envelope for Wrapfully workers.
133
+ */
134
+ export async function createEnvelopeFromInnerZip (innerZip, {publicKeyPem, kid, resultKeyPair}) {
135
+ const innerBuf = Buffer.isBuffer(innerZip) ? innerZip : await bufferFromStream(innerZip);
136
+ const {key, ciphertext} = aesEncrypt(innerBuf);
137
+ const wrapped = wrapKey(key, publicKeyPem);
138
+ const meta = {
139
+ kid,
140
+ alg: ALG,
141
+ resultPub: resultKeyPair.publicKey,
142
+ };
143
+
144
+ const zip = await zipToBuffer(async (archive) => {
145
+ archive.append(ciphertext, {name: ENVELOPE_DATA});
146
+ archive.append(wrapped, {name: ENVELOPE_KEY});
147
+ archive.append(JSON.stringify(meta, null, 2), {name: ENVELOPE_META});
148
+ archive.append(resultKeyPair.publicKey, {name: 'resultPub.pem'});
149
+ });
150
+
151
+ return {zip, resultPriv: resultKeyPair.privateKey, kid};
152
+ }
153
+
154
+ /**
155
+ * Decrypt a completed Yap result envelope into a plaintext zip Buffer.
156
+ */
157
+ export async function decryptResultEnvelope (resultZipBuf, resultPrivPem) {
158
+ if (!resultPrivPem) {
159
+ throw new Error('Missing resultPriv for encrypted Wrapfully chore download.');
160
+ }
161
+
162
+ const tmp = path.join(os.tmpdir(), `adaptfully-result-${Date.now().toString(16)}`);
163
+
164
+ fs.mkdirSync(tmp, {recursive: true});
165
+ try {
166
+ await pipeline(Readable.from(resultZipBuf), unzipper.Extract({path: tmp}));
167
+ if (!fs.existsSync(path.join(tmp, RESULT_DATA))) {
168
+ // Not an envelope — return as-is
169
+ return resultZipBuf;
170
+ }
171
+ const ciphertext = fs.readFileSync(path.join(tmp, RESULT_DATA));
172
+ const wrapped = fs.readFileSync(path.join(tmp, RESULT_KEY));
173
+ const aesKey = unwrapKey(wrapped, resultPrivPem);
174
+
175
+ return aesDecrypt(aesKey, ciphertext);
176
+ } finally {
177
+ fs.rmSync(tmp, {recursive: true, force: true});
178
+ }
179
+ }
180
+
181
+ export function isResultEnvelopeBuffer (buf) {
182
+ // Weak check — PK zip; decryptResultEnvelope falls back if members missing
183
+ return Buffer.isBuffer(buf) && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b;
184
+ }
185
+
186
+ export {
187
+ ENVELOPE_DATA,
188
+ ENVELOPE_KEY,
189
+ ENVELOPE_META,
190
+ RESULT_DATA,
191
+ RESULT_KEY,
192
+ ALG,
193
+ aesEncrypt,
194
+ aesDecrypt,
195
+ wrapKey,
196
+ unwrapKey,
197
+ };
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
 
3
- const DEFAULT_SERVER = 'http://localhost:9633/';
3
+ const DEFAULT_SHOWFULLY_SERVER = process.env.SHOWFULLY_DEV
4
+ ? 'http://localhost:9630/'
5
+ : 'https://make.makefullystudios.com/';
4
6
 
5
7
  /**
6
8
  * @param {string} [projectRoot='.']
@@ -29,14 +31,53 @@ export async function loadProjectConfig(projectRoot = '.') {
29
31
  }
30
32
 
31
33
  /**
32
- * @param {{ server?: string }} wrapfullyConfig
34
+ * Showfully Yap base URL (not Wrapfully HTTP).
35
+ * @param {{ server?: string, showfullyServer?: string }} wrapfullyConfig
33
36
  * @param {string} [cliServer]
34
37
  */
35
38
  export function resolveServerUrl(wrapfullyConfig, cliServer) {
36
39
  return (
37
40
  cliServer
38
- || process.env.WRAPFULLY_SERVER
41
+ || process.env.SHOWFULLY_SERVER
42
+ || wrapfullyConfig.showfullyServer
39
43
  || wrapfullyConfig.server
40
- || DEFAULT_SERVER
44
+ || process.env.WRAPFULLY_SERVER
45
+ || DEFAULT_SHOWFULLY_SERVER
41
46
  ).replace(/\/?$/, '/');
42
47
  }
48
+
49
+ /**
50
+ * Required PAT for Yap submit/poll/download.
51
+ * @param {{ accessToken?: string, showfullyPat?: string }} wrapfullyConfig
52
+ * @param {string} [cliToken]
53
+ */
54
+ export function resolveAccessToken(wrapfullyConfig = {}, cliToken) {
55
+ const token = (
56
+ cliToken
57
+ || process.env.SHOWFULLY_PAT
58
+ || process.env.WRAPFULLY_ACCESS_TOKEN
59
+ || wrapfullyConfig.accessToken
60
+ || wrapfullyConfig.showfullyPat
61
+ || ''
62
+ ).trim();
63
+
64
+ if (!token) {
65
+ throw new Error(
66
+ 'Showfully PAT required. Set SHOWFULLY_PAT or wrapfully.json "accessToken" '
67
+ + '(Settings → API tokens on play.makefullystudios.com).',
68
+ );
69
+ }
70
+
71
+ return token;
72
+ }
73
+
74
+ /**
75
+ * @param {{ encrypt?: boolean }} wrapfullyConfig
76
+ * @param {boolean} [cliEncrypt]
77
+ */
78
+ export function resolveEncryptFlag(wrapfullyConfig = {}, cliEncrypt) {
79
+ if (typeof cliEncrypt === 'boolean') {
80
+ return cliEncrypt;
81
+ }
82
+ return !!wrapfullyConfig.encrypt;
83
+ }
@@ -1,37 +1,157 @@
1
1
  import axios from 'axios';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { pipeline } from 'node:stream/promises';
4
+ import {Readable} from 'node:stream';
5
+ import {pipeline} from 'node:stream/promises';
5
6
  import unzipper from 'unzip-stream';
6
- import { createDeployArchive, createReleaseArchive, createSourceArchive } from './archive.js';
7
- import { clearStaleBuildExtract } from './artifacts.js';
8
- import { listHtmlFilesRecursive } from './fs-utils.js';
9
- import { printBuildReport } from './report.js';
7
+ import {createDeployArchive, createReleaseArchive, createSourceArchive} from './archive.js';
8
+ import {clearStaleBuildExtract} from './artifacts.js';
9
+ import {listHtmlFilesRecursive} from './fs-utils.js';
10
+ import {printBuildReport} from './report.js';
11
+ import {
12
+ createEnvelopeFromInnerZip,
13
+ decryptResultEnvelope,
14
+ generateResultKeyPair,
15
+ resolveEncryptPublicKey,
16
+ } from './choreCrypto.js';
17
+
18
+ const POLL_MS = 5000;
10
19
 
11
20
  /**
12
21
  * @param {string} gameId
13
22
  * @param {string} platformKey
14
23
  */
15
- function buildInfoParam(gameId, platformKey) {
24
+ export function buildInfoParam (gameId, platformKey) {
16
25
  return platformKey && platformKey !== gameId ? `${gameId}_${platformKey}` : gameId;
17
26
  }
18
27
 
19
28
  /**
20
- * @param {string} server
21
- * @param {'build' | 'deploy' | 'release'} stage
22
- * @param {string} family
23
- * @param {string} gameId
24
- * @param {string} platformKey
25
- * @param {string} [deploymentKey]
29
+ * Build wrapfully.json routing config appended to the Yap zip.
26
30
  */
27
- function resolveWrapfullyUrl(server, stage, family, gameId, platformKey, deploymentKey) {
28
- const info = buildInfoParam(gameId, platformKey);
31
+ export function buildWrapfullyJobConfig (stage, family, gameId, platformKey, deploymentKey) {
32
+ /** @type {Record<string, string>} */
33
+ const job = {
34
+ stage,
35
+ route: family,
36
+ platformKey,
37
+ gameId,
38
+ };
29
39
 
30
40
  if (stage === 'deploy') {
31
- return `${server}deploy/${deploymentKey}/${info}`;
41
+ if (!deploymentKey) {
42
+ throw new Error('deploy stage requires deploymentKey');
43
+ }
44
+ job.deploymentKey = deploymentKey;
45
+ }
46
+
47
+ return job;
48
+ }
49
+
50
+ async function streamToBuffer (stream) {
51
+ const chunks = [];
52
+
53
+ for await (const chunk of stream) {
54
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
32
55
  }
33
56
 
34
- return `${server}${family}/${stage}/${info}`;
57
+ return Buffer.concat(chunks);
58
+ }
59
+
60
+ async function sleep (ms) {
61
+ await new Promise((resolve) => setTimeout(resolve, ms));
62
+ }
63
+
64
+ /**
65
+ * Submit zip to Showfully Yap, poll until complete, return result zip Buffer.
66
+ */
67
+ export async function submitWrapfullyChore ({
68
+ server,
69
+ accessToken,
70
+ zipBuffer,
71
+ log = console.log,
72
+ }) {
73
+ const base = server.replace(/\/?$/, '/');
74
+ const submitUrl = `${base}yap/wrapfully`;
75
+ const headers = {
76
+ Authorization: `Bearer ${accessToken}`,
77
+ 'Content-Type': 'application/zip',
78
+ };
79
+
80
+ log(`adaptfully: POST ${submitUrl}`);
81
+
82
+ let submitJson;
83
+
84
+ try {
85
+ const {data} = await axios.post(submitUrl, zipBuffer, {
86
+ maxRedirects: 0,
87
+ headers,
88
+ maxBodyLength: Infinity,
89
+ maxContentLength: Infinity,
90
+ validateStatus: () => true,
91
+ });
92
+
93
+ submitJson = data;
94
+ } catch (err) {
95
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ECONNREFUSED') {
96
+ throw new Error(`Cannot connect to Showfully server "${server}"`);
97
+ }
98
+ throw err;
99
+ }
100
+
101
+ if (submitJson?.errors?.length) {
102
+ throw new Error(submitJson.errors[0]);
103
+ }
104
+ if (!submitJson?.choreId) {
105
+ throw new Error('Showfully did not return a choreId');
106
+ }
107
+
108
+ const {choreId} = submitJson;
109
+
110
+ log(`adaptfully: chore ${choreId} submitted; waiting…`);
111
+
112
+ let lastStatus = '';
113
+
114
+ for (;;) {
115
+ const statusUrl = `${base}yap/wrapfully/${encodeURIComponent(choreId)}/status`;
116
+ const {data: status} = await axios.get(statusUrl, {
117
+ headers: {Authorization: `Bearer ${accessToken}`},
118
+ validateStatus: () => true,
119
+ });
120
+
121
+ if (status?.state === 'complete') {
122
+ break;
123
+ }
124
+ if (status?.state === 'error' || status?.errors?.length) {
125
+ throw new Error(status.errors?.[0] || `Chore ${choreId} failed`);
126
+ }
127
+
128
+ const message = String(status?.status || status?.state || 'waiting');
129
+
130
+ if (message !== lastStatus) {
131
+ log(`adaptfully: ${message}…`);
132
+ lastStatus = message;
133
+ }
134
+ await sleep(POLL_MS);
135
+ }
136
+
137
+ log(`adaptfully: downloading chore ${choreId}`);
138
+ const downloadUrl = `${base}yap/wrapfully/${encodeURIComponent(choreId)}`;
139
+ const {data, headers: resHeaders} = await axios.get(downloadUrl, {
140
+ headers: {Authorization: `Bearer ${accessToken}`},
141
+ responseType: 'arraybuffer',
142
+ maxContentLength: Infinity,
143
+ validateStatus: () => true,
144
+ });
145
+
146
+ const ctype = resHeaders['content-type'] || '';
147
+
148
+ if (ctype.includes('application/json')) {
149
+ const json = JSON.parse(Buffer.from(data).toString('utf8'));
150
+
151
+ throw new Error(json.errors?.[0] || 'Chore download returned JSON');
152
+ }
153
+
154
+ return Buffer.from(data);
35
155
  }
36
156
 
37
157
  /**
@@ -41,23 +161,45 @@ function resolveWrapfullyUrl(server, stage, family, gameId, platformKey, deploym
41
161
  * @param {'build' | 'deploy' | 'release'} stage
42
162
  * @param {string} family
43
163
  * @param {string} deployFolder
44
- * @param {{ name: string, version: string }} pkg
164
+ * @param {{ name: string, version: string, config?: object }} pkg
45
165
  * @param {'extract' | string} mode
46
- * @param {{ log?: (message: string) => void, publishDir?: string, deploymentDirs?: string[], platformKey?: string, deploymentKey?: string, artifactPath?: string }} [options]
166
+ * @param {{
167
+ * log?: (message: string) => void,
168
+ * publishDir?: string,
169
+ * deploymentDirs?: string[],
170
+ * platformKey?: string,
171
+ * deploymentKey?: string,
172
+ * artifactPath?: string,
173
+ * accessToken?: string,
174
+ * encrypt?: boolean,
175
+ * encryptPublicKey?: string,
176
+ * wrapfullyConfig?: object,
177
+ * }} [options]
47
178
  */
48
- export async function send(gameId, contents, server, stage, family, deployFolder, pkg, mode = 'extract', options = {}) {
179
+ export async function send (
180
+ gameId,
181
+ contents,
182
+ server,
183
+ stage,
184
+ family,
185
+ deployFolder,
186
+ pkg,
187
+ mode = 'extract',
188
+ options = {},
189
+ ) {
49
190
  const log = options.log ?? console.log;
50
191
  const platformKey = options.platformKey ?? family;
51
192
  const outputRoot = pkg.config?.outputFolder || 'output';
193
+ const accessToken = options.accessToken;
194
+
195
+ if (!accessToken) {
196
+ throw new Error('accessToken (Showfully PAT) is required for Yap wrapfully chores');
197
+ }
52
198
 
53
199
  if (mode === 'extract') {
54
200
  clearStaleBuildExtract(outputRoot);
55
201
  }
56
202
 
57
- const destination = mode === 'extract'
58
- ? unzipper.Extract({ path: `${outputRoot}/`, concurrency: 1 })
59
- : fs.createWriteStream(`${outputRoot}/${pkg.name}-${pkg.version}-${stage}-${family}.zip`);
60
-
61
203
  let archiveStream;
62
204
 
63
205
  if (stage === 'deploy') {
@@ -67,9 +209,9 @@ export async function send(gameId, contents, server, stage, family, deployFolder
67
209
  if (!options.deploymentKey) {
68
210
  throw new Error('deploy stage requires options.deploymentKey');
69
211
  }
70
- archiveStream = createDeployArchive(options.artifactPath, options.publishDir, contents, { log });
212
+ archiveStream = createDeployArchive(options.artifactPath, options.publishDir, contents, {log});
71
213
  } else if (stage === 'release') {
72
- archiveStream = createReleaseArchive(deployFolder, contents, options.deploymentDirs ?? [], { log });
214
+ archiveStream = createReleaseArchive(deployFolder, contents, options.deploymentDirs ?? [], {log});
73
215
  } else {
74
216
  archiveStream = createSourceArchive(deployFolder, contents, {
75
217
  log,
@@ -78,31 +220,108 @@ export async function send(gameId, contents, server, stage, family, deployFolder
78
220
  });
79
221
  }
80
222
 
81
- const url = resolveWrapfullyUrl(server, stage, family, gameId, platformKey, options.deploymentKey);
82
- log(`adaptfully: POST ${url}`);
83
-
84
223
  if (stage !== 'deploy') {
85
224
  const htmlFiles = listHtmlFilesRecursive(deployFolder);
225
+
86
226
  log(`adaptfully: sending ${htmlFiles.length} HTML file(s) from ${path.resolve(deployFolder)}`);
87
227
  }
88
228
 
89
- const { data } = await axios.post(url, archiveStream, {
90
- maxRedirects: 0,
91
- responseType: 'stream',
92
- });
229
+ // Append wrapfully.json routing into the archive by rebuilding into a buffer.
230
+ const job = buildWrapfullyJobConfig(stage, family, gameId, platformKey, options.deploymentKey);
231
+ const innerChunks = [];
232
+
233
+ // Re-pack: read archiveStream into zip, add wrapfully.json
234
+ // Archiver streams aren't easily mutable — collect then use yazl-free approach:
235
+ // pipe archive to buffer, unzip to tmp, add file, rezip is heavy.
236
+ // Simpler: createSourceArchive already finalized as stream — append via second archiver
237
+ // that copies entries is complex. Instead append wrapfully.json by concatenating
238
+ // a sidecar zip is wrong. Best: write wrapfully.json into deploy folder meta before
239
+ // archive... but that mutates disk.
240
+ //
241
+ // Practical approach matching Dutifully: build archive, buffer it, then use a new
242
+ // archiver that includes the buffer as... no that's nested.
243
+ //
244
+ // Use jszip-like: buffer the created archive by consuming stream, then use
245
+ // adm-zip equivalent — we have unzipper. Extract to tmp, write wrapfully.json, rezip.
246
+
247
+ const tmpRoot = path.join(outputRoot, `.adaptfully-yap-${Date.now().toString(16)}`);
248
+
249
+ fs.mkdirSync(tmpRoot, {recursive: true});
250
+ try {
251
+ await pipeline(archiveStream, unzipper.Extract({path: tmpRoot, concurrency: 1}));
252
+ fs.writeFileSync(path.join(tmpRoot, 'wrapfully.json'), JSON.stringify(job, null, 2));
253
+
254
+ const {default: archiver} = await import('archiver');
255
+ const {PassThrough} = await import('node:stream');
256
+ const archive = archiver('zip', {zlib: {level: 0}});
257
+ const pass = new PassThrough();
93
258
 
94
- archiveStream.on('close', () => {
95
- log('adaptfully: upload complete');
259
+ archive.pipe(pass);
260
+ pass.on('data', (c) => innerChunks.push(c));
261
+
262
+ const done = new Promise((resolve, reject) => {
263
+ pass.on('end', resolve);
264
+ pass.on('error', reject);
265
+ archive.on('error', reject);
266
+ });
267
+
268
+ archive.directory(tmpRoot, false);
269
+ await archive.finalize();
270
+ await done;
271
+ } finally {
272
+ fs.rmSync(tmpRoot, {recursive: true, force: true});
273
+ }
274
+
275
+ let uploadBuffer = Buffer.concat(innerChunks);
276
+ let resultPriv = null;
277
+
278
+ if (options.encrypt) {
279
+ const {pem, kid} = resolveEncryptPublicKey(
280
+ options.wrapfullyConfig || {},
281
+ options.encryptPublicKey,
282
+ );
283
+ const resultKeyPair = generateResultKeyPair();
284
+ const envelope = await createEnvelopeFromInnerZip(uploadBuffer, {
285
+ publicKeyPem: pem,
286
+ kid,
287
+ resultKeyPair,
288
+ });
289
+
290
+ uploadBuffer = envelope.zip;
291
+ resultPriv = envelope.resultPriv;
292
+ log(`adaptfully: encrypting chore with kid "${kid}"`);
293
+ }
294
+
295
+ let resultBuffer = await submitWrapfullyChore({
296
+ server,
297
+ accessToken,
298
+ zipBuffer: uploadBuffer,
299
+ log,
96
300
  });
97
301
 
302
+ if (resultPriv) {
303
+ try {
304
+ resultBuffer = await decryptResultEnvelope(resultBuffer, resultPriv);
305
+ } catch (err) {
306
+ throw new Error(`Failed to decrypt Wrapfully result envelope: ${err.message || err}`);
307
+ }
308
+ }
309
+
310
+ if (!Buffer.isBuffer(resultBuffer) || resultBuffer.length < 4
311
+ || resultBuffer[0] !== 0x50 || resultBuffer[1] !== 0x4b) {
312
+ throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
313
+ }
314
+
315
+ const destination = mode === 'extract'
316
+ ? unzipper.Extract({path: `${outputRoot}/`, concurrency: 1})
317
+ : fs.createWriteStream(`${outputRoot}/${pkg.name}-${pkg.version}-${stage}-${family}.zip`);
318
+
98
319
  try {
99
- await pipeline(data, destination);
320
+ await pipeline(Readable.from(resultBuffer), destination);
100
321
  } catch (err) {
101
- if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ECONNREFUSED') {
102
- console.error(`Cannot connect to Wrapfully server "${server}"`);
103
- process.exit(1);
104
- }
105
- throw err;
322
+ throw new Error(
323
+ `Failed to unpack chore result (${resultBuffer.length} bytes): ${err.message || err}`
324
+ );
106
325
  }
107
326
 
108
327
  if (mode === 'extract') {
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { loadProjectConfig, resolveServerUrl } from './config.js';
2
+ import { loadProjectConfig, resolveServerUrl, resolveAccessToken, resolveEncryptFlag } from './config.js';
3
3
  import { send } from './deploy.js';
4
4
  import { resolveBuildArtifactDir } from './artifacts.js';
5
5
  import { prebuildPlatform } from './prebuild.js';
@@ -44,7 +44,7 @@ export function resolveWrapfullyRoute(buildSpec) {
44
44
  /**
45
45
  * @param {AdaptfullyStage} stage
46
46
  * @param {string} platformKey
47
- * @param {{ pkg: object, deployFolder: string, server?: string, mode?: string, deployment?: string, artifactPath?: string, log?: (message: string) => void, outputRoot?: string }} options
47
+ * @param {{ pkg: object, deployFolder: string, server?: string, mode?: string, deployment?: string, artifactPath?: string, log?: (message: string) => void, outputRoot?: string, accessToken?: string, encrypt?: boolean, encryptPublicKey?: string, wrapfullyConfig?: object }} options
48
48
  */
49
49
  export async function runAdaptfullyStage(stage, platformKey, options) {
50
50
  if (!VALID_STAGES.has(stage)) {
@@ -103,7 +103,7 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
103
103
 
104
104
  log(
105
105
  `adaptfully: ${stage} ${platformKey} → ${buildSpec.family} `
106
- + `(${buildSpec.targets.join(', ')}) via /${wrapfullyRoute}/release`,
106
+ + `(${buildSpec.targets.join(', ')}) via Yap /yap/wrapfully (route ${wrapfullyRoute}/release)`,
107
107
  );
108
108
 
109
109
  log(
@@ -125,6 +125,10 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
125
125
  log,
126
126
  platformKey,
127
127
  deploymentDirs: deploymentDirs.map((dir) => path.resolve(dir)),
128
+ accessToken: options.accessToken,
129
+ encrypt: options.encrypt,
130
+ encryptPublicKey: options.encryptPublicKey,
131
+ wrapfullyConfig: options.wrapfullyConfig,
128
132
  },
129
133
  );
130
134
 
@@ -142,7 +146,7 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
142
146
  log(
143
147
  `adaptfully: ${stage} ${platformKey} → `
144
148
  + `${deploymentKey === 'zip' ? 'artifact zip' : `deployment "${deploymentKey}"`} `
145
- + `via Wrapfully /${wrapfullyRoute}/${wrapStage} (${buildSpec.family})`,
149
+ + `via Yap /yap/wrapfully (route ${wrapfullyRoute}/${wrapStage}, ${buildSpec.family})`,
146
150
  );
147
151
 
148
152
  if (deploymentDirs.length) {
@@ -169,6 +173,10 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
169
173
  platformKey,
170
174
  deploymentKey: wrapStage === 'deploy' ? deploymentKey : undefined,
171
175
  artifactPath,
176
+ accessToken: options.accessToken,
177
+ encrypt: options.encrypt,
178
+ encryptPublicKey: options.encryptPublicKey,
179
+ wrapfullyConfig: options.wrapfullyConfig,
172
180
  },
173
181
  );
174
182
 
@@ -194,7 +202,7 @@ export async function adaptfullyFromCli(argv = process.argv) {
194
202
  }
195
203
 
196
204
  const stage = command;
197
- const { positionals, deployment, artifactPath } = parseStageArgs(argv.slice(3));
205
+ const { positionals, deployment, artifactPath, encrypt, encryptPublicKey } = parseStageArgs(argv.slice(3));
198
206
  const platformKey = positionals[0];
199
207
  const cliServer = positionals[1];
200
208
  const mode = positionals[2] ?? 'extract';
@@ -202,7 +210,8 @@ export async function adaptfullyFromCli(argv = process.argv) {
202
210
  if (!stage || !platformKey) {
203
211
  throw new Error(
204
212
  'Usage:\n'
205
- + ' adaptfully <prebuild|build|deploy|release> <platform> [server] [mode] [--deployment <key>] [--artifact <path>]\n'
213
+ + ' adaptfully <prebuild|build|deploy|release> <platform> [showfullyServer] [mode]\n'
214
+ + ' [--deployment <key>] [--artifact <path>] [--encrypt] [--encrypt-public-key <pem|path>]\n'
206
215
  + ' adaptfully steam-publish [--username U] [--password P] [--deployment steam] [--output path]\n'
207
216
  + ' adaptfully google-publish [--from service-account.json] [--deployment android] [--output path]\n'
208
217
  + ' adaptfully apple-publish [--deployment ios] [--category C] [--identity I] [--username U] [--password P]\n'
@@ -211,12 +220,18 @@ export async function adaptfullyFromCli(argv = process.argv) {
211
220
  + '\n'
212
221
  + 'Stages:\n'
213
222
  + ' prebuild Copy deploy/ and apply platform registrations → output/<platform>-prebuild/\n'
214
- + ' build prebuild + POST /{target|family}/build (zip artifact only)\n'
215
- + ' deploy POST /deploy/{key} with prior artifact + deployment credentials\n'
216
- + ' release prebuild + POST /{target|family}/release (build + configured deployments)\n'
223
+ + ' build prebuild + POST /yap/wrapfully (zip artifact only; Showfully PAT required)\n'
224
+ + ' deploy Yap deploy chore with prior artifact + deployment credentials\n'
225
+ + ' release Yap release chore (build + configured deployments)\n'
217
226
  + '\n'
218
227
  + ' --deployment <key> Target a single named deployment.\n'
219
228
  + ' --artifact <path> Prior build artifact directory (deploy only; default: ./output/ after build).\n'
229
+ + ' --encrypt Envelope-encrypt chore to Wrapfully (optional; default key embedded).\n'
230
+ + ' --encrypt-public-key <pem|path> Override Wrapfully public key when encrypting.\n'
231
+ + '\n'
232
+ + 'Auth / server (wrapfully.json or env):\n'
233
+ + ' SHOWFULLY_PAT / accessToken required PAT from play.makefullystudios.com Settings\n'
234
+ + ' SHOWFULLY_SERVER / server Showfully Yap base (default https://make.makefullystudios.com/)\n'
220
235
  + '\n'
221
236
  + 'Publish credential helpers (one-time local setup):\n'
222
237
  + ' steam-publish Install steamcmd if needed, log in, write steam deployment steam.json\n'
@@ -229,6 +244,8 @@ export async function adaptfullyFromCli(argv = process.argv) {
229
244
 
230
245
  const { pkg, wrapfullyConfig } = await loadProjectConfig();
231
246
  const server = resolveServerUrl(wrapfullyConfig, cliServer);
247
+ const accessToken = resolveAccessToken(wrapfullyConfig);
248
+ const encryptFlag = resolveEncryptFlag(wrapfullyConfig, encrypt);
232
249
  const deployFolder = pkg.config?.deployFolder || 'deploy';
233
250
 
234
251
  return runAdaptfullyStage(/** @type {AdaptfullyStage} */ (stage), platformKey, {
@@ -238,18 +255,24 @@ export async function adaptfullyFromCli(argv = process.argv) {
238
255
  mode,
239
256
  deployment,
240
257
  artifactPath,
258
+ accessToken,
259
+ encrypt: encryptFlag,
260
+ encryptPublicKey,
261
+ wrapfullyConfig,
241
262
  });
242
263
  }
243
264
 
244
265
  /**
245
266
  * @param {string[]} args
246
- * @returns {{ positionals: string[], deployment?: string, artifactPath?: string }}
267
+ * @returns {{ positionals: string[], deployment?: string, artifactPath?: string, encrypt?: boolean, encryptPublicKey?: string }}
247
268
  */
248
269
  export function parseStageArgs(args) {
249
270
  /** @type {string[]} */
250
271
  const positionals = [];
251
272
  let deployment;
252
273
  let artifactPath;
274
+ let encrypt;
275
+ let encryptPublicKey;
253
276
 
254
277
  for (let i = 0; i < args.length; i++) {
255
278
  const arg = args[i];
@@ -262,10 +285,16 @@ export function parseStageArgs(args) {
262
285
  artifactPath = args[++i];
263
286
  } else if (arg.startsWith('--artifact=')) {
264
287
  artifactPath = arg.slice('--artifact='.length);
288
+ } else if (arg === '--encrypt') {
289
+ encrypt = true;
290
+ } else if (arg === '--encrypt-public-key' && args[i + 1]) {
291
+ encryptPublicKey = args[++i];
292
+ } else if (arg.startsWith('--encrypt-public-key=')) {
293
+ encryptPublicKey = arg.slice('--encrypt-public-key='.length);
265
294
  } else {
266
295
  positionals.push(arg);
267
296
  }
268
297
  }
269
298
 
270
- return { positionals, deployment, artifactPath };
299
+ return { positionals, deployment, artifactPath, encrypt, encryptPublicKey };
271
300
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Default Makefully Wrapfully fleet public key (kid: makefully-wrapfully-1).
3
+ * Private key lives only on Wrapfully workers (WRAPFULLY_PRIVATE_KEY / cred.json).
4
+ * Safe to embed in clients — used only to wrap chore AES keys when encrypt: true.
5
+ */
6
+ export const WRAPFULLY_DEFAULT_KID = 'makefully-wrapfully-1';
7
+
8
+ export const WRAPFULLY_DEFAULT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
9
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArbQGAXiX+XtnsdS0yUPj
10
+ dgM2Vt8lwDXPtLxzjYD45fr/NEOct0+ECsvr3jyIB/cVCjINpJJyKGEBvZk8woFj
11
+ FgyodSWjJrc1UsomeOz6IcOArWu6GnkFo0jn8R0FN8LOhEl1xjqT7VPzF4jd7/P5
12
+ 4WBNeB+wjkY/XBmk5wsNLvGaREp8G4fFfMgDNPpTUy7RB8ezolTiWZH0KgiXZtM8
13
+ yUXk5YBPxTwI28wCB6Om5RGmwozWc66k29qZQRv5d4GRMSM18lMKh3gkjptZRhRD
14
+ D48bKDQwYPX8CWcpl/Q9qm2L0wtikjQWro1NsAreqa3r91wlrsYZ82stYIyKBYhW
15
+ LwIDAQAB
16
+ -----END PUBLIC KEY-----
17
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/adaptfully",
3
- "version": "4.1.0",
3
+ "version": "4.2.1",
4
4
  "description": "Platform abstraction and Wrapfully deploy client for Makefully games",
5
5
  "type": "module",
6
6
  "main": "./lib/node/index.js",