@callcorpacd/sandbox-cli 0.1.0
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/README.md +19 -0
- package/package.json +20 -0
- package/src/cli.js +70 -0
- package/src/config.js +149 -0
- package/src/embedSnippet.js +56 -0
- package/src/login.js +74 -0
- package/src/publish.js +151 -0
- package/src/whoami.js +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @callcorpacd/sandbox-cli
|
|
2
|
+
|
|
3
|
+
CLI for CallCorp sandboxed guest apps.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
callcorp-sandbox login
|
|
7
|
+
callcorp-sandbox whoami
|
|
8
|
+
callcorp-sandbox embed-snippet --mode dev|prod
|
|
9
|
+
callcorp-sandbox publish [--target platform-file|mass-storage] [--no-build] [--owner-id <id>]
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Credentials are resolved in order:
|
|
13
|
+
|
|
14
|
+
1. `~/.callcorp/sandbox.json` (API base + API key and/or access token)
|
|
15
|
+
2. A parent BuildCC workspace: `.buildcc/secrets.json` (`apiKey`) and optional `.buildcc/workspace.json` (`host`)
|
|
16
|
+
|
|
17
|
+
If either source has a key, `publish` / `whoami` work without running `login`. Interactive `login` defaults to a discovered BuildCC key when present.
|
|
18
|
+
|
|
19
|
+
See [`../PlatformBridge/AUTHORING.md`](../PlatformBridge/AUTHORING.md). Template catalog: [`../create-callcorp-sandbox/templates/README.md`](../create-callcorp-sandbox/templates/README.md). Partners: [`../create-callcorp-sandbox/PARTNER.md`](../create-callcorp-sandbox/PARTNER.md). Scaffolded apps include a stack-specific `AGENTS.md` for assistant-oriented guidance.
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@callcorpacd/sandbox-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Login, publish, and embed-snippet helpers for CallCorp sandboxed guest apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"callcorp-sandbox": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/cli.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loginCommand } from './login.js';
|
|
5
|
+
import { whoamiCommand } from './whoami.js';
|
|
6
|
+
import { publishCommand } from './publish.js';
|
|
7
|
+
import { embedSnippetCommand } from './embedSnippet.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
function printHelp() {
|
|
12
|
+
console.log(`callcorp-sandbox — CallCorp sandboxed guest tooling
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
login Store API base URL + API key (or access token)
|
|
16
|
+
whoami Validate credentials (Apps/User/FullName)
|
|
17
|
+
publish [--target ...] Build (unless --no-build) and upload dist/
|
|
18
|
+
embed-snippet --mode dev|prod Print SandboxedApp JSON for the current project
|
|
19
|
+
|
|
20
|
+
Global options:
|
|
21
|
+
--cwd <path> Project directory (default: cwd)
|
|
22
|
+
-h, --help Show help
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseGlobal(argv) {
|
|
27
|
+
const out = { cmd: null, args: [], cwd: process.cwd() };
|
|
28
|
+
for (let i = 2; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === '-h' || a === '--help') out.help = true;
|
|
31
|
+
else if (a === '--cwd') out.cwd = path.resolve(argv[++i]);
|
|
32
|
+
else if (!out.cmd && !a.startsWith('-')) out.cmd = a;
|
|
33
|
+
else out.args.push(a);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
const opts = parseGlobal(process.argv);
|
|
40
|
+
if (opts.help || !opts.cmd) {
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exit(opts.help ? 0 : 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.chdir(opts.cwd);
|
|
46
|
+
|
|
47
|
+
switch (opts.cmd) {
|
|
48
|
+
case 'login':
|
|
49
|
+
await loginCommand(opts.args);
|
|
50
|
+
break;
|
|
51
|
+
case 'whoami':
|
|
52
|
+
await whoamiCommand(opts.args);
|
|
53
|
+
break;
|
|
54
|
+
case 'publish':
|
|
55
|
+
await publishCommand(opts.args);
|
|
56
|
+
break;
|
|
57
|
+
case 'embed-snippet':
|
|
58
|
+
await embedSnippetCommand(opts.args);
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
console.error(`Unknown command: ${opts.cmd}`);
|
|
62
|
+
printHelp();
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main().catch((err) => {
|
|
68
|
+
console.error(err?.message || err);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
package/src/config.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function configPath() {
|
|
6
|
+
return path.join(os.homedir(), '.callcorp', 'sandbox.json');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function loadConfig() {
|
|
10
|
+
const p = configPath();
|
|
11
|
+
if (!fs.existsSync(p))
|
|
12
|
+
return null;
|
|
13
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveConfig(config) {
|
|
17
|
+
const p = configPath();
|
|
18
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
19
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadManifest(cwd = process.cwd()) {
|
|
24
|
+
const p = path.join(cwd, 'callcorp.sandbox.json');
|
|
25
|
+
if (!fs.existsSync(p))
|
|
26
|
+
throw new Error(`callcorp.sandbox.json not found in ${cwd}`);
|
|
27
|
+
return { path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeApiBase(hostOrUrl) {
|
|
31
|
+
const raw = String(hostOrUrl || 'api.callcorp.com').trim().replace(/\/+$/, '');
|
|
32
|
+
if (/^https?:\/\//i.test(raw))
|
|
33
|
+
return raw;
|
|
34
|
+
return `https://${raw}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walk ancestors for a BuildCC workspace (`.buildcc/secrets.json` with apiKey).
|
|
39
|
+
* Same idea as BuildCC WorkspaceConfig.ResolveWorkspaceRoot.
|
|
40
|
+
* @returns {{ apiBase: string, apiKey: string, source: string } | null}
|
|
41
|
+
*/
|
|
42
|
+
export function findBuildCcAuth(startDir = process.cwd()) {
|
|
43
|
+
let dir = path.resolve(startDir);
|
|
44
|
+
for (;;) {
|
|
45
|
+
const buildCcDir = path.join(dir, '.buildcc');
|
|
46
|
+
const secretsPath = path.join(buildCcDir, 'secrets.json');
|
|
47
|
+
if (fs.existsSync(secretsPath)) {
|
|
48
|
+
try {
|
|
49
|
+
const secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
|
|
50
|
+
const apiKey = secrets?.apiKey || secrets?.ApiKey;
|
|
51
|
+
if (apiKey && String(apiKey).trim()) {
|
|
52
|
+
let host = 'api.callcorp.com';
|
|
53
|
+
const workspacePath = path.join(buildCcDir, 'workspace.json');
|
|
54
|
+
if (fs.existsSync(workspacePath)) {
|
|
55
|
+
const binding = JSON.parse(fs.readFileSync(workspacePath, 'utf8'));
|
|
56
|
+
host = binding?.host || binding?.Host || host;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
apiBase: normalizeApiBase(host),
|
|
60
|
+
apiKey: String(apiKey).trim(),
|
|
61
|
+
source: secretsPath,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// keep walking
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const parent = path.dirname(dir);
|
|
69
|
+
if (parent === dir)
|
|
70
|
+
break;
|
|
71
|
+
dir = parent;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function requireAuth() {
|
|
77
|
+
const cfg = loadConfig();
|
|
78
|
+
if (cfg?.apiBase && (cfg.apiKey || cfg.accessToken))
|
|
79
|
+
return cfg;
|
|
80
|
+
|
|
81
|
+
const buildCc = findBuildCcAuth();
|
|
82
|
+
if (buildCc) {
|
|
83
|
+
return {
|
|
84
|
+
apiBase: buildCc.apiBase,
|
|
85
|
+
apiKey: buildCc.apiKey,
|
|
86
|
+
accessToken: undefined,
|
|
87
|
+
updatedAt: undefined,
|
|
88
|
+
fromBuildCc: buildCc.source,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!cfg?.apiBase)
|
|
93
|
+
throw new Error('Not logged in. Run: callcorp-sandbox login (or bind a BuildCC workspace with .buildcc/secrets.json)');
|
|
94
|
+
throw new Error('No apiKey or accessToken in config. Run: callcorp-sandbox login');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function apiUrl(apiBase, pathAndQuery) {
|
|
98
|
+
const base = String(apiBase).replace(/\/+$/, '');
|
|
99
|
+
const path = String(pathAndQuery).replace(/^\/+/, '');
|
|
100
|
+
return `${base}/${path}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Authenticated fetch. Prefers access_token header; also sends apikey query when present
|
|
105
|
+
* (BuildCC-style non-interactive auth).
|
|
106
|
+
*/
|
|
107
|
+
export async function apiFetch(cfg, method, pathAndQuery, { body, headers, isBinary } = {}) {
|
|
108
|
+
let url = apiUrl(cfg.apiBase, pathAndQuery);
|
|
109
|
+
if (cfg.apiKey) {
|
|
110
|
+
const u = new URL(url);
|
|
111
|
+
u.searchParams.set('apikey', cfg.apiKey);
|
|
112
|
+
url = u.toString();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const reqHeaders = { ...(headers || {}) };
|
|
116
|
+
if (cfg.accessToken)
|
|
117
|
+
reqHeaders.access_token = cfg.accessToken;
|
|
118
|
+
|
|
119
|
+
const init = { method: method || 'GET', headers: reqHeaders };
|
|
120
|
+
if (body !== undefined) {
|
|
121
|
+
init.body = body;
|
|
122
|
+
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
123
|
+
if (!isBinary && !isForm && !reqHeaders['Content-Type'] && typeof body === 'string')
|
|
124
|
+
reqHeaders['Content-Type'] = 'application/json';
|
|
125
|
+
if (isForm)
|
|
126
|
+
delete reqHeaders['Content-Type'];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const res = await fetch(url, init);
|
|
130
|
+
const text = await res.text();
|
|
131
|
+
let data = text;
|
|
132
|
+
try {
|
|
133
|
+
data = text ? JSON.parse(text) : null;
|
|
134
|
+
} catch {
|
|
135
|
+
// keep text
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const msg = typeof data === 'object' && data
|
|
140
|
+
? (data.Message || data.message || JSON.stringify(data))
|
|
141
|
+
: text;
|
|
142
|
+
const err = new Error(`API ${method} ${pathAndQuery} failed (${res.status}): ${String(msg).slice(0, 400)}`);
|
|
143
|
+
err.status = res.status;
|
|
144
|
+
err.data = data;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return data;
|
|
149
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { loadManifest } from './config.js';
|
|
2
|
+
|
|
3
|
+
function parseArgs(args) {
|
|
4
|
+
const out = { mode: 'dev' };
|
|
5
|
+
for (let i = 0; i < args.length; i++) {
|
|
6
|
+
if (args[i] === '--mode') out.mode = args[++i];
|
|
7
|
+
}
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function embedSnippetCommand(args) {
|
|
12
|
+
const { mode } = parseArgs(args);
|
|
13
|
+
const { data: manifest } = loadManifest();
|
|
14
|
+
const prefixes = manifest.allowedApiPrefixes || ['Apps/User/'];
|
|
15
|
+
const appId = manifest.appId || 'app';
|
|
16
|
+
const port = manifest.localDevPort || 5174;
|
|
17
|
+
const contentPath = (manifest.contentPath || `PartnerContent/${appId}`).replace(/\/+$/, '');
|
|
18
|
+
const target = manifest.target || 'platform-file';
|
|
19
|
+
|
|
20
|
+
let snippet;
|
|
21
|
+
// Params: flat string map; values may use portal interpolations ({{ }} / {# #}).
|
|
22
|
+
// Guest reads them via platform.context.params after handshake.
|
|
23
|
+
const exampleParams = {
|
|
24
|
+
Mode: mode === 'dev' ? 'dev' : 'prod',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (mode === 'dev') {
|
|
28
|
+
snippet = {
|
|
29
|
+
ControlType: 'SandboxedApp',
|
|
30
|
+
ControlData: {
|
|
31
|
+
Name: `${appId}Dev`,
|
|
32
|
+
SourceKind: 'Url',
|
|
33
|
+
Url: manifest.localDevUrl || `https://localhost:${port}/`,
|
|
34
|
+
AllowedApiPrefixes: prefixes,
|
|
35
|
+
Params: exampleParams,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
else if (mode === 'prod') {
|
|
40
|
+
const controlData = {
|
|
41
|
+
Name: appId,
|
|
42
|
+
SourceKind: target === 'mass-storage' ? 'MassStorage' : 'PlatformFile',
|
|
43
|
+
ContentPath: `${contentPath}/index.html`,
|
|
44
|
+
AllowedApiPrefixes: prefixes,
|
|
45
|
+
Params: exampleParams,
|
|
46
|
+
};
|
|
47
|
+
if (target === 'mass-storage')
|
|
48
|
+
controlData.OwnerId = '{# Root.CustomerID #}';
|
|
49
|
+
snippet = { ControlType: 'SandboxedApp', ControlData: controlData };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
throw new Error(`Unknown mode: ${mode} (use dev|prod)`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(JSON.stringify(snippet, null, 2));
|
|
56
|
+
}
|
package/src/login.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import readline from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { loadConfig, saveConfig, findBuildCcAuth } from './config.js';
|
|
4
|
+
|
|
5
|
+
function parseArgs(args) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (let i = 0; i < args.length; i++) {
|
|
8
|
+
const a = args[i];
|
|
9
|
+
if (a === '--api-base') out.apiBase = args[++i];
|
|
10
|
+
else if (a === '--api-key') out.apiKey = args[++i];
|
|
11
|
+
else if (a === '--access-token') out.accessToken = args[++i];
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function prompt(rl, question, fallback = '', displayFallback) {
|
|
17
|
+
const shown = displayFallback !== undefined ? displayFallback : fallback;
|
|
18
|
+
const suffix = shown ? ` (${shown})` : '';
|
|
19
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
20
|
+
return answer || fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function maskKey(key) {
|
|
24
|
+
const s = String(key || '');
|
|
25
|
+
if (s.length <= 8)
|
|
26
|
+
return '********';
|
|
27
|
+
return `${s.slice(0, 4)}…${s.slice(-4)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loginCommand(args) {
|
|
31
|
+
const flags = parseArgs(args);
|
|
32
|
+
const existing = loadConfig() || {};
|
|
33
|
+
const buildCc = findBuildCcAuth();
|
|
34
|
+
const rl = readline.createInterface({ input, output });
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const defaultBase = flags.apiBase
|
|
38
|
+
|| existing.apiBase
|
|
39
|
+
|| buildCc?.apiBase
|
|
40
|
+
|| 'https://api.callcorp.com';
|
|
41
|
+
const defaultKey = flags.apiKey || existing.apiKey || buildCc?.apiKey || '';
|
|
42
|
+
|
|
43
|
+
if (buildCc && !flags.apiKey && !existing.apiKey)
|
|
44
|
+
console.log(`Found BuildCC API key at ${buildCc.source} (${maskKey(buildCc.apiKey)})`);
|
|
45
|
+
|
|
46
|
+
const apiBase = flags.apiBase
|
|
47
|
+
|| await prompt(rl, 'API base URL', defaultBase);
|
|
48
|
+
const apiKey = flags.apiKey
|
|
49
|
+
|| await prompt(
|
|
50
|
+
rl,
|
|
51
|
+
'API key (preferred for CLI; leave blank to use access token)',
|
|
52
|
+
defaultKey,
|
|
53
|
+
defaultKey ? maskKey(defaultKey) : '',
|
|
54
|
+
);
|
|
55
|
+
let accessToken = flags.accessToken || existing.accessToken || '';
|
|
56
|
+
if (!apiKey && !flags.accessToken)
|
|
57
|
+
accessToken = await prompt(rl, 'Access token', accessToken);
|
|
58
|
+
|
|
59
|
+
if (!apiKey && !accessToken)
|
|
60
|
+
throw new Error('Provide an API key or an access token');
|
|
61
|
+
|
|
62
|
+
const cfg = {
|
|
63
|
+
apiBase: apiBase.replace(/\/+$/, ''),
|
|
64
|
+
apiKey: apiKey || undefined,
|
|
65
|
+
accessToken: accessToken || undefined,
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
const p = saveConfig(cfg);
|
|
69
|
+
console.log(`Saved credentials to ${p}`);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rl.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/publish.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { requireAuth, loadManifest, apiFetch } from './config.js';
|
|
5
|
+
|
|
6
|
+
function parseArgs(args) {
|
|
7
|
+
const out = { noBuild: false, target: null, dist: 'dist' };
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const a = args[i];
|
|
10
|
+
if (a === '--no-build') out.noBuild = true;
|
|
11
|
+
else if (a === '--target') out.target = args[++i];
|
|
12
|
+
else if (a === '--dist') out.dist = args[++i];
|
|
13
|
+
else if (a === '--owner-id') out.ownerId = args[++i];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function walkFiles(dir) {
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
const full = path.join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory())
|
|
23
|
+
results.push(...walkFiles(full));
|
|
24
|
+
else
|
|
25
|
+
results.push(full);
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function metaDataTypeForFile(filePath) {
|
|
31
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
32
|
+
if (ext === '.html' || ext === '.htm') return 'Html';
|
|
33
|
+
if (ext === '.js' || ext === '.mjs') return 'Javascript';
|
|
34
|
+
if (ext === '.css') return 'Css';
|
|
35
|
+
if (ext === '.json') return 'Json';
|
|
36
|
+
if (ext === '.png') return 'Png';
|
|
37
|
+
if (ext === '.jpg' || ext === '.jpeg') return 'Jpeg';
|
|
38
|
+
if (ext === '.svg') return 'Svg';
|
|
39
|
+
if (ext === '.woff2') return 'Woff2';
|
|
40
|
+
if (ext === '.woff') return 'Woff';
|
|
41
|
+
if (ext === '.map') return 'Json';
|
|
42
|
+
return 'Binary';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function contentTypeForFile(filePath) {
|
|
46
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
47
|
+
const map = {
|
|
48
|
+
'.html': 'text/html',
|
|
49
|
+
'.htm': 'text/html',
|
|
50
|
+
'.js': 'text/javascript',
|
|
51
|
+
'.mjs': 'text/javascript',
|
|
52
|
+
'.css': 'text/css',
|
|
53
|
+
'.json': 'application/json',
|
|
54
|
+
'.png': 'image/png',
|
|
55
|
+
'.jpg': 'image/jpeg',
|
|
56
|
+
'.jpeg': 'image/jpeg',
|
|
57
|
+
'.svg': 'image/svg+xml',
|
|
58
|
+
'.woff': 'font/woff',
|
|
59
|
+
'.woff2': 'font/woff2',
|
|
60
|
+
'.map': 'application/json',
|
|
61
|
+
};
|
|
62
|
+
return map[ext] || 'application/octet-stream';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function uploadPlatformFile(cfg, remotePath, localFile) {
|
|
66
|
+
const bytes = fs.readFileSync(localFile);
|
|
67
|
+
// Apps/FileBinary2?FullNameWithPath=... multipart (same as BasicFileUpload default)
|
|
68
|
+
const form = new FormData();
|
|
69
|
+
const blob = new Blob([bytes], { type: contentTypeForFile(localFile) });
|
|
70
|
+
form.append('uploadedFile', blob, path.basename(localFile));
|
|
71
|
+
|
|
72
|
+
const q = encodeURIComponent(remotePath);
|
|
73
|
+
await apiFetch(cfg, 'POST', `Apps/FileBinary2?FullNameWithPath=${q}`, {
|
|
74
|
+
body: form,
|
|
75
|
+
isBinary: true,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function uploadMassStorage(cfg, ownerId, remotePath, localFile) {
|
|
80
|
+
const bytes = fs.readFileSync(localFile);
|
|
81
|
+
const meta = metaDataTypeForFile(localFile);
|
|
82
|
+
const pathPart = `${encodeURIComponent(ownerId)}/${remotePath.split('/').map(encodeURIComponent).join('/')}`;
|
|
83
|
+
await apiFetch(
|
|
84
|
+
cfg,
|
|
85
|
+
'PUT',
|
|
86
|
+
`Apps/FileMassStorage/${pathPart}?metaDataType=${encodeURIComponent(meta)}&isBinary=true`,
|
|
87
|
+
{
|
|
88
|
+
body: bytes,
|
|
89
|
+
headers: { 'Content-Type': contentTypeForFile(localFile) },
|
|
90
|
+
isBinary: true,
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function publishCommand(args) {
|
|
96
|
+
const flags = parseArgs(args);
|
|
97
|
+
const cfg = requireAuth();
|
|
98
|
+
const { data: manifest } = loadManifest();
|
|
99
|
+
const target = flags.target || manifest.target || 'platform-file';
|
|
100
|
+
const contentRoot = (manifest.contentPath || `PartnerContent/${manifest.appId || 'app'}`).replace(/^\/+|\/+$/g, '');
|
|
101
|
+
const distDir = path.resolve(process.cwd(), flags.dist);
|
|
102
|
+
|
|
103
|
+
if (!flags.noBuild) {
|
|
104
|
+
console.log('Running npm run build…');
|
|
105
|
+
// Windows: spawnSync('npm.cmd', args, { shell: false }) fails with EINVAL.
|
|
106
|
+
// Pass a single command string with shell: true (avoids DEP0190 arg concatenation).
|
|
107
|
+
const build = spawnSync('npm run build', {
|
|
108
|
+
stdio: 'inherit',
|
|
109
|
+
shell: true,
|
|
110
|
+
cwd: process.cwd(),
|
|
111
|
+
env: process.env,
|
|
112
|
+
});
|
|
113
|
+
if (build.error)
|
|
114
|
+
throw new Error(`Build failed to start: ${build.error.message}`);
|
|
115
|
+
if (build.status !== 0)
|
|
116
|
+
throw new Error(`Build failed (exit ${build.status ?? 'unknown'})`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(distDir))
|
|
120
|
+
throw new Error(`dist not found: ${distDir} (run npm run build or omit --no-build)`);
|
|
121
|
+
|
|
122
|
+
const files = walkFiles(distDir);
|
|
123
|
+
if (files.length === 0)
|
|
124
|
+
throw new Error(`No files in ${distDir}`);
|
|
125
|
+
|
|
126
|
+
let ownerId = flags.ownerId || cfg.ownerId || manifest.ownerId;
|
|
127
|
+
if (target === 'mass-storage' && !ownerId) {
|
|
128
|
+
// Best-effort: whoami-style call may not return CustomerID; require explicit owner.
|
|
129
|
+
throw new Error('Mass Storage publish requires --owner-id (customer/owner id)');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`Publishing ${files.length} file(s) to ${target} under ${contentRoot}/`);
|
|
133
|
+
|
|
134
|
+
for (const localFile of files) {
|
|
135
|
+
const rel = path.relative(distDir, localFile).split(path.sep).join('/');
|
|
136
|
+
const remotePath = `${contentRoot}/${rel}`;
|
|
137
|
+
process.stdout.write(` ${remotePath} … `);
|
|
138
|
+
if (target === 'mass-storage')
|
|
139
|
+
await uploadMassStorage(cfg, ownerId, remotePath, localFile);
|
|
140
|
+
else
|
|
141
|
+
await uploadPlatformFile(cfg, remotePath, localFile);
|
|
142
|
+
console.log('ok');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log('\nPublish complete.');
|
|
146
|
+
console.log('Prod SandboxedApp ContentPath:', `${contentRoot}/index.html`);
|
|
147
|
+
if (target === 'platform-file')
|
|
148
|
+
console.log('SourceKind: PlatformFile');
|
|
149
|
+
else
|
|
150
|
+
console.log(`SourceKind: MassStorage, OwnerId: ${ownerId}`);
|
|
151
|
+
}
|
package/src/whoami.js
ADDED