suitescript-verify 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +54 -2
- package/dist/bin/suitescript-verify.js +3 -0
- package/dist/certificate.js +116 -0
- package/dist/cli.js +196 -0
- package/dist/config.js +141 -0
- package/dist/doctor.js +97 -0
- package/dist/envfile.js +56 -0
- package/dist/init.js +35 -0
- package/dist/netsuite.js +65 -0
- package/dist/probes.js +93 -0
- package/dist/results.js +33 -0
- package/dist/run.js +184 -0
- package/dist/sdf.js +53 -0
- package/dist/shared.js +132 -0
- package/package.json +75 -4
- package/templates/runner.template.js +64 -0
- package/templates/sdf/deploy.xml +8 -0
- package/templates/sdf/manifest.xml +12 -0
- package/templates/sdf/package.json +5 -0
- package/templates/sdf/runner.xml +17 -0
- package/templates/sdf/suitecloud.config.js +4 -0
package/dist/envfile.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { parseEnv } from "./config.js";
|
|
2
|
+
/** The variables `env set` accepts. The passkey has its own command, layout keys live in the committed `.env`. */
|
|
3
|
+
export const envNames = [
|
|
4
|
+
"SUITESCRIPT_VERIFY_ACCOUNT",
|
|
5
|
+
"SUITESCRIPT_VERIFY_CLIENT_ID",
|
|
6
|
+
"SUITESCRIPT_VERIFY_CERTIFICATE_ID",
|
|
7
|
+
"SUITESCRIPT_VERIFY_PRIVATE_KEY_PATH",
|
|
8
|
+
"SUITESCRIPT_VERIFY_SDF_CERTIFICATE_ID",
|
|
9
|
+
"SUITESCRIPT_VERIFY_SDF_PRIVATE_KEY_PATH",
|
|
10
|
+
"SUITESCRIPT_VERIFY_DOMAIN",
|
|
11
|
+
];
|
|
12
|
+
const header = "# Local credentials for suitescript-verify. This file is gitignored.\n# Do not commit it and do not copy these values elsewhere.\n";
|
|
13
|
+
/** Forward slashes keep a Windows path valid inside a dotenv file. */
|
|
14
|
+
export const envValue = (value) => {
|
|
15
|
+
const normalized = value.replace(/\\/g, "/");
|
|
16
|
+
if (!/[\s#"']/.test(normalized))
|
|
17
|
+
return normalized;
|
|
18
|
+
// The reader does not unescape, so quote with a character the value lacks.
|
|
19
|
+
if (!normalized.includes('"'))
|
|
20
|
+
return `"${normalized}"`;
|
|
21
|
+
if (!normalized.includes("'"))
|
|
22
|
+
return `'${normalized}'`;
|
|
23
|
+
throw new Error("A value can not contain both kinds of quote.");
|
|
24
|
+
};
|
|
25
|
+
export function planEnvUpdate(text, entries) {
|
|
26
|
+
const current = parseEnv(text);
|
|
27
|
+
const plan = { conflicts: [], fresh: [], same: [] };
|
|
28
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
29
|
+
const existing = current.get(name);
|
|
30
|
+
if (existing === undefined || existing === "")
|
|
31
|
+
plan.fresh.push(name);
|
|
32
|
+
else if (existing === value.replace(/\\/g, "/") || existing === value) {
|
|
33
|
+
plan.same.push(name);
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
plan.conflicts.push(name);
|
|
37
|
+
}
|
|
38
|
+
return plan;
|
|
39
|
+
}
|
|
40
|
+
/** Writes the entries, replacing a conflicting value only if its name is in `overwrite`. */
|
|
41
|
+
export function applyEnvUpdate(text, entries, overwrite) {
|
|
42
|
+
const plan = planEnvUpdate(text, entries);
|
|
43
|
+
const write = new Set([
|
|
44
|
+
...plan.fresh,
|
|
45
|
+
...plan.conflicts.filter((name) => overwrite.has(name)),
|
|
46
|
+
]);
|
|
47
|
+
let out = text === "" ? header : text;
|
|
48
|
+
for (const name of write) {
|
|
49
|
+
const line = `${name}=${envValue(entries[name] ?? "")}`;
|
|
50
|
+
const pattern = new RegExp(`^(\\s*(?:export\\s+)?${name}\\s*=).*$`, "m");
|
|
51
|
+
out = pattern.test(out)
|
|
52
|
+
? out.replace(pattern, () => line)
|
|
53
|
+
: `${out.endsWith("\n") ? out : `${out}\n`}${line}\n`;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Scaffolds the folders and a committed `.env` of layout keys, then returns
|
|
5
|
+
* the to-do list for the person. Creates no credential and installs nothing.
|
|
6
|
+
*/
|
|
7
|
+
export function init(project, config) {
|
|
8
|
+
const rel = (path) => relative(project, path).replaceAll("\\", "/");
|
|
9
|
+
const dirs = [config.probesDir, config.resultsDir, config.sdfDir];
|
|
10
|
+
for (const dir of dirs)
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
const envFile = join(config.root, ".env");
|
|
13
|
+
const created = !existsSync(envFile);
|
|
14
|
+
if (created) {
|
|
15
|
+
mkdirSync(dirname(envFile), { recursive: true });
|
|
16
|
+
writeFileSync(envFile, [
|
|
17
|
+
"# Layout only: this file is committed. Credentials go in .env.local, which is gitignored.",
|
|
18
|
+
`SUITESCRIPT_VERIFY_PROBES_DIR=${rel(config.probesDir)}`,
|
|
19
|
+
`SUITESCRIPT_VERIFY_RESULTS_DIR=${rel(config.resultsDir)}`,
|
|
20
|
+
`SUITESCRIPT_VERIFY_SDF_DIR=${rel(config.sdfDir)}`,
|
|
21
|
+
"",
|
|
22
|
+
].join("\n"));
|
|
23
|
+
}
|
|
24
|
+
const local = rel(join(config.root, ".env.local"));
|
|
25
|
+
return [
|
|
26
|
+
`${created ? "Created" : "Kept"} ${rel(envFile)} and the folders ${dirs.map(rel).join(", ")}. No credentials were created.`,
|
|
27
|
+
"",
|
|
28
|
+
"To do (each step is yours, the maintainer's):",
|
|
29
|
+
"1. Install the SuiteCloud CLI: npm install --save-dev @oracle/suitecloud-cli. Read Oracle's license and accept it yourself; this package never does.",
|
|
30
|
+
`2. Add ${local} to .gitignore before saving any value: it holds credentials.`,
|
|
31
|
+
"3. In a sandbox, create the integration record, the role and the certificate mappings, and generate the private keys outside the repository.",
|
|
32
|
+
`4. Save the account, client id, certificate ids, key paths and a passkey in ${local} (see docs/verification/README.md).`,
|
|
33
|
+
"5. Run suitescript-verify doctor until every step passes.",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
package/dist/netsuite.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
2
|
+
export const runnerScriptId = "customscript_suitescript_verify_runner";
|
|
3
|
+
export const runnerDeployId = "customdeploy_suitescript_verify_runner";
|
|
4
|
+
export const fileCabinetFolder = "suitescript-verify";
|
|
5
|
+
const host = (account) => account.trim().toLowerCase().replace(/_/g, "-");
|
|
6
|
+
export const tokenUrl = (account) => `https://${host(account)}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`;
|
|
7
|
+
export const restletUrl = (account) => `https://${host(account)}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=${runnerScriptId}&deploy=${runnerDeployId}`;
|
|
8
|
+
const base64url = (data) => Buffer.from(data).toString("base64url");
|
|
9
|
+
/** The signed JWT NetSuite's OAuth 2.0 client credentials flow expects: ES256 with an EC P-256 key. */
|
|
10
|
+
export function clientAssertion(options) {
|
|
11
|
+
const key = createPrivateKey(options.privateKeyPem);
|
|
12
|
+
const curve = key.asymmetricKeyDetails?.namedCurve;
|
|
13
|
+
if (key.asymmetricKeyType !== "ec" || curve !== "prime256v1") {
|
|
14
|
+
throw new Error("The runner private key must be an EC P-256 key (ES256). Generate one with `suitescript-verify certificate`.");
|
|
15
|
+
}
|
|
16
|
+
const iat = Math.floor((options.now ?? Date.now()) / 1000);
|
|
17
|
+
const header = { alg: "ES256", kid: options.certificateId, typ: "JWT" };
|
|
18
|
+
const claims = {
|
|
19
|
+
aud: options.tokenEndpoint,
|
|
20
|
+
exp: iat + 55 * 60,
|
|
21
|
+
iat,
|
|
22
|
+
iss: options.clientId,
|
|
23
|
+
scope: "restlets",
|
|
24
|
+
};
|
|
25
|
+
const input = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`;
|
|
26
|
+
const signature = sign("sha256", Buffer.from(input), {
|
|
27
|
+
dsaEncoding: "ieee-p1363",
|
|
28
|
+
key,
|
|
29
|
+
});
|
|
30
|
+
return `${input}.${base64url(signature)}`;
|
|
31
|
+
}
|
|
32
|
+
const identifierPatterns = [
|
|
33
|
+
["an account number", /\b\d{5,8}[_-](?:SB|RP|TSTDRV)\w*\b/i],
|
|
34
|
+
["an account number", /\bTSTDRV\d+\b/i],
|
|
35
|
+
["a NetSuite host", /[a-z0-9-]+\.[a-z0-9.-]*netsuite\.com/i],
|
|
36
|
+
["a JWT", /\beyJ[\w-]{10,}\.[\w-]{10,}\./],
|
|
37
|
+
["a long hex token", /\b[a-f0-9]{40,}\b/i],
|
|
38
|
+
["a PEM block", /-----BEGIN [A-Z ]+-----/],
|
|
39
|
+
["an email address", /[\w.+-]+@[\w-]+\.[\w.-]+/],
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Replaces the configured account, client and certificate ids with
|
|
43
|
+
* placeholders, then reports anything that still looks like an identifier.
|
|
44
|
+
* Callers fail closed: they print or write nothing when `problems` is not empty.
|
|
45
|
+
*/
|
|
46
|
+
export function scrub(text, known) {
|
|
47
|
+
let out = text;
|
|
48
|
+
const replacements = [
|
|
49
|
+
[known.account, "<ACCOUNT>"],
|
|
50
|
+
[known.clientId, "<CLIENT-ID>"],
|
|
51
|
+
[known.certificateId, "<CERTIFICATE-ID>"],
|
|
52
|
+
[known.sdfCertificateId, "<SDF-CERTIFICATE-ID>"],
|
|
53
|
+
];
|
|
54
|
+
for (const [value, placeholder] of replacements) {
|
|
55
|
+
if (value && value.length >= 4) {
|
|
56
|
+
out = out.split(value).join(placeholder);
|
|
57
|
+
out = out.split(value.toLowerCase()).join(placeholder);
|
|
58
|
+
out = out.split(value.replace(/_/g, "-").toLowerCase()).join(placeholder);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const problems = identifierPatterns
|
|
62
|
+
.filter(([, pattern]) => pattern.test(out))
|
|
63
|
+
.map(([label]) => `the output looks like it contains ${label}`);
|
|
64
|
+
return { problems: [...new Set(problems)], text: out };
|
|
65
|
+
}
|
package/dist/probes.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
export const read = (path) => readFileSync(path, "utf8").replace(/\r\n/g, "\n");
|
|
5
|
+
/** A probe name is a lowercase slug: it ends up in a file name and the runner's fixed list. */
|
|
6
|
+
export const isSlug = (name) => /^[a-z0-9][a-z0-9-]*$/.test(name);
|
|
7
|
+
const paramName = /^[a-z][A-Za-z0-9]*$/;
|
|
8
|
+
/** The whole value must match: a declared pattern is anchored, so `[0-9]+` cannot match `1;x`. */
|
|
9
|
+
export const anchored = (pattern) => new RegExp(`^(?:${pattern})$`, "u");
|
|
10
|
+
function parseParams(block) {
|
|
11
|
+
const params = [];
|
|
12
|
+
const paramProblems = [];
|
|
13
|
+
for (const match of block.matchAll(/^[ \t]*\*?[ \t]*@param\b[ \t]*(.*)$/gm)) {
|
|
14
|
+
const line = (match[1] ?? "").trim();
|
|
15
|
+
const [, name = "", pattern = ""] = /^(\S*)[ \t]*(.*)$/.exec(line) ?? [];
|
|
16
|
+
if (!paramName.test(name)) {
|
|
17
|
+
paramProblems.push(`@param "${name}" needs a name like recordType: @param <name> <pattern>`);
|
|
18
|
+
}
|
|
19
|
+
else if (params.some((param) => param.name === name)) {
|
|
20
|
+
paramProblems.push(`@param ${name} is declared more than once`);
|
|
21
|
+
}
|
|
22
|
+
else if (pattern === "") {
|
|
23
|
+
paramProblems.push(`@param ${name} needs a pattern: @param ${name} <pattern>`);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
try {
|
|
27
|
+
anchored(pattern);
|
|
28
|
+
params.push({ name, pattern });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
paramProblems.push(`@param ${name} has an invalid pattern: ${pattern}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { paramProblems, params };
|
|
36
|
+
}
|
|
37
|
+
/** The tags the package gives meaning to, and the ordinary NetSuite and JSDoc ones it lets through. */
|
|
38
|
+
const builtinTags = new Set([
|
|
39
|
+
"scriptType",
|
|
40
|
+
"writes",
|
|
41
|
+
"param",
|
|
42
|
+
"NApiVersion",
|
|
43
|
+
"NScriptType",
|
|
44
|
+
"NModuleScope",
|
|
45
|
+
"see",
|
|
46
|
+
"link",
|
|
47
|
+
"example",
|
|
48
|
+
"returns",
|
|
49
|
+
"since",
|
|
50
|
+
"deprecated",
|
|
51
|
+
].map((name) => name.toLowerCase()));
|
|
52
|
+
/**
|
|
53
|
+
* Reads the tags in a probe's leading JSDoc block. A tag that is neither the
|
|
54
|
+
* package's own nor in `extraTags` (registered by the project) is reported.
|
|
55
|
+
*/
|
|
56
|
+
export function parseProbe(slug, source, extraTags = []) {
|
|
57
|
+
const block = /^\s*\/\*\*([\s\S]*?)\*\//.exec(source)?.[1] ?? "";
|
|
58
|
+
const tags = (name) => [...block.matchAll(new RegExp(`@${name}\\b[ \\t]*([^\\n*]*)`, "g"))].map((match) => (match[1] ?? "").trim());
|
|
59
|
+
const registered = new Set(extraTags.map((name) => name.toLowerCase()));
|
|
60
|
+
const unknownTags = [
|
|
61
|
+
...new Set([...block.matchAll(/^[ \t]*\*?[ \t]*@([A-Za-z][\w-]*)/gm)].map((match) => match[1] ?? "")),
|
|
62
|
+
].filter((name) => !builtinTags.has(name.toLowerCase()) &&
|
|
63
|
+
!registered.has(name.toLowerCase()));
|
|
64
|
+
return {
|
|
65
|
+
...parseParams(block),
|
|
66
|
+
scriptType: tags("scriptType")[0] ?? "",
|
|
67
|
+
slug,
|
|
68
|
+
unknownTags,
|
|
69
|
+
writes: tags("writes").length > 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function listProbes(dir, extraTags = []) {
|
|
73
|
+
if (!existsSync(dir))
|
|
74
|
+
return [];
|
|
75
|
+
return readdirSync(dir)
|
|
76
|
+
.filter((file) => file.endsWith(".js"))
|
|
77
|
+
.map((file) => file.replace(/\.js$/, ""))
|
|
78
|
+
.filter(isSlug)
|
|
79
|
+
.sort()
|
|
80
|
+
.map((slug) => parseProbe(slug, read(join(dir, `${slug}.js`)), extraTags));
|
|
81
|
+
}
|
|
82
|
+
export const isRestlet = (probe) => probe.scriptType.toLowerCase() === "restlet";
|
|
83
|
+
/** The output a probe produced when it was verified, if it declared one. */
|
|
84
|
+
export function readExpect(slug, dir) {
|
|
85
|
+
const path = join(dir, `${slug}.expect.json`);
|
|
86
|
+
return existsSync(path) ? JSON.parse(read(path)) : undefined;
|
|
87
|
+
}
|
|
88
|
+
export const matchesExpect = (result, expected) => isDeepStrictEqual(result, expected);
|
|
89
|
+
/** What is wrong with a probe's tags, for the commands that refuse to run it. */
|
|
90
|
+
export const tagProblems = (probe) => [
|
|
91
|
+
...probe.paramProblems,
|
|
92
|
+
...probe.unknownTags.map((name) => `@${name} is not a tag the package knows; register it with SUITESCRIPT_VERIFY_EXTRA_TAGS=${name}`),
|
|
93
|
+
];
|
package/dist/results.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
/** A draft results page: the test, the NetSuite version, the results, and empty explanation and decision sections for a person to fill in. */
|
|
3
|
+
export function resultsDraft(options) {
|
|
4
|
+
const link = relative(options.resultsDir, options.probeFile).replaceAll("\\", "/");
|
|
5
|
+
return `# TODO(verify): what was verified
|
|
6
|
+
|
|
7
|
+
- **NetSuite version:** ${options.version}
|
|
8
|
+
- **Account type:** sandbox
|
|
9
|
+
- **Date:** ${options.date}
|
|
10
|
+
- **Method:** deployed RESTlet (the suitescript-verify runner)
|
|
11
|
+
- **Probe:** [${options.probe.slug}](${link})
|
|
12
|
+
|
|
13
|
+
## Test
|
|
14
|
+
|
|
15
|
+
\`\`\`js
|
|
16
|
+
${options.scriptSource.trimEnd()}
|
|
17
|
+
\`\`\`
|
|
18
|
+
|
|
19
|
+
## Results
|
|
20
|
+
|
|
21
|
+
\`\`\`json
|
|
22
|
+
${JSON.stringify(options.output, null, 2)}
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
## Explanation
|
|
26
|
+
|
|
27
|
+
TODO(verify): what the results mean, and how they relate to what each source claims.
|
|
28
|
+
|
|
29
|
+
## Decision
|
|
30
|
+
|
|
31
|
+
TODO(verify): what was decided because of the results.
|
|
32
|
+
`;
|
|
33
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { clientAssertion, restletUrl, scrub, tokenUrl } from "./netsuite.js";
|
|
4
|
+
import { anchored, isRestlet, isSlug, listProbes, matchesExpect, read, readExpect, tagProblems, } from "./probes.js";
|
|
5
|
+
import { resultsDraft } from "./results.js";
|
|
6
|
+
import { positionals, requireSandbox, UsageError } from "./shared.js";
|
|
7
|
+
/** Every `--param name=value` in the arguments, in order. The value may start with anything. */
|
|
8
|
+
function suppliedParams(args) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (let i = 0; i < args.length; i++) {
|
|
11
|
+
if (args[i] !== "--param")
|
|
12
|
+
continue;
|
|
13
|
+
const text = args[++i] ?? "";
|
|
14
|
+
const at = text.indexOf("=");
|
|
15
|
+
if (at < 1) {
|
|
16
|
+
throw new UsageError(`--param needs name=value, got "${text}".`);
|
|
17
|
+
}
|
|
18
|
+
out.push([text.slice(0, at), text.slice(at + 1)]);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Checks the supplied parameters against what the probe declares, before any
|
|
24
|
+
* request. A value is only ever data: it goes into the JSON body, and the
|
|
25
|
+
* runner passes it to the probe as a string, never evaluating it.
|
|
26
|
+
*/
|
|
27
|
+
function validateParams(probe, args) {
|
|
28
|
+
const problems = tagProblems(probe);
|
|
29
|
+
if (problems.length > 0) {
|
|
30
|
+
throw new UsageError(`${probe.slug} has invalid tags:\n${problems.map((m) => `- ${m}`).join("\n")}`);
|
|
31
|
+
}
|
|
32
|
+
const supplied = suppliedParams(args);
|
|
33
|
+
if (probe.params.length === 0) {
|
|
34
|
+
if (supplied.length > 0) {
|
|
35
|
+
throw new UsageError(`${probe.slug} takes no parameters; it declares no @param tags.`);
|
|
36
|
+
}
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
const values = new Map();
|
|
40
|
+
for (const [name, value] of supplied) {
|
|
41
|
+
if (!probe.params.some((param) => param.name === name)) {
|
|
42
|
+
throw new UsageError(`Parameter "${name}" is not declared by ${probe.slug}. Declared: ${probe.params.map((p) => p.name).join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
if (values.has(name)) {
|
|
45
|
+
throw new UsageError(`Parameter "${name}" was given more than once.`);
|
|
46
|
+
}
|
|
47
|
+
values.set(name, value);
|
|
48
|
+
}
|
|
49
|
+
for (const { name, pattern } of probe.params) {
|
|
50
|
+
const value = values.get(name);
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
throw new UsageError(`${probe.slug} needs --param ${name}=<value> matching ${pattern}`);
|
|
53
|
+
}
|
|
54
|
+
if (!anchored(pattern).test(value)) {
|
|
55
|
+
throw new UsageError(`Parameter "${name}" does not match ${pattern}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Object.fromEntries(probe.params.map(({ name }) => [name, values.get(name) ?? ""]));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Runs one probe, by name from the fixed list in the probes folder, through
|
|
62
|
+
* the deployed runner. Nothing from a request or an argument is ever run as
|
|
63
|
+
* code: the runner only knows the probes generated into it by `deploy`.
|
|
64
|
+
*/
|
|
65
|
+
export async function runProbe(project, config, args) {
|
|
66
|
+
const creds = requireSandbox(config);
|
|
67
|
+
const name = positionals(args)[0] ?? "";
|
|
68
|
+
const probes = listProbes(config.probesDir, config.extraTags);
|
|
69
|
+
const probe = isSlug(name)
|
|
70
|
+
? probes.find((candidate) => candidate.slug === name)
|
|
71
|
+
: undefined;
|
|
72
|
+
if (!probe) {
|
|
73
|
+
throw new UsageError(`Unknown probe "${name}". Known: ${probes.map((p) => p.slug).join(", ") || "(none)"}`);
|
|
74
|
+
}
|
|
75
|
+
if (!isRestlet(probe)) {
|
|
76
|
+
throw new UsageError(`${probe.slug} needs a ${probe.scriptType || "(undeclared)"} script. The runner only runs RESTlet probes (declare @scriptType Restlet); ask the user to run it by hand (docs/verification/README.md).`);
|
|
77
|
+
}
|
|
78
|
+
if (probe.writes) {
|
|
79
|
+
throw new UsageError(`${probe.slug} declares @writes, which needs the user's separate approval. That path is not automated.`);
|
|
80
|
+
}
|
|
81
|
+
const params = validateParams(probe, args);
|
|
82
|
+
if (!existsSync(creds.privateKeyPath)) {
|
|
83
|
+
throw new UsageError("The runner private key file does not exist.");
|
|
84
|
+
}
|
|
85
|
+
const known = {
|
|
86
|
+
account: creds.account,
|
|
87
|
+
certificateId: creds.certificateId,
|
|
88
|
+
clientId: creds.clientId,
|
|
89
|
+
sdfCertificateId: creds.sdfCertificateId,
|
|
90
|
+
};
|
|
91
|
+
const safe = (text) => scrub(text, known).text;
|
|
92
|
+
const endpoint = tokenUrl(creds.account);
|
|
93
|
+
const assertion = clientAssertion({
|
|
94
|
+
certificateId: creds.certificateId,
|
|
95
|
+
clientId: creds.clientId,
|
|
96
|
+
privateKeyPem: readFileSync(creds.privateKeyPath, "utf8"),
|
|
97
|
+
tokenEndpoint: endpoint,
|
|
98
|
+
});
|
|
99
|
+
let text;
|
|
100
|
+
try {
|
|
101
|
+
const tokenResponse = await fetch(endpoint, {
|
|
102
|
+
body: new URLSearchParams({
|
|
103
|
+
client_assertion: assertion,
|
|
104
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
105
|
+
grant_type: "client_credentials",
|
|
106
|
+
}),
|
|
107
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
108
|
+
method: "POST",
|
|
109
|
+
});
|
|
110
|
+
const tokenBody = (await tokenResponse.json().catch(() => ({})));
|
|
111
|
+
if (!tokenResponse.ok || !tokenBody.access_token) {
|
|
112
|
+
throw new UsageError(safe(`Token request failed: HTTP ${tokenResponse.status} ${tokenBody.error ?? ""} ${tokenBody.error_description ?? ""}`.trim()));
|
|
113
|
+
}
|
|
114
|
+
const response = await fetch(restletUrl(creds.account), {
|
|
115
|
+
body: JSON.stringify(probe.params.length > 0
|
|
116
|
+
? { params, probe: probe.slug }
|
|
117
|
+
: { probe: probe.slug }),
|
|
118
|
+
headers: {
|
|
119
|
+
authorization: `Bearer ${tokenBody.access_token}`,
|
|
120
|
+
"content-type": "application/json",
|
|
121
|
+
},
|
|
122
|
+
method: "POST",
|
|
123
|
+
});
|
|
124
|
+
text = await response.text();
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw new UsageError(`The runner call failed: HTTP ${response.status}\n${safe(text.slice(0, 500))}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof UsageError)
|
|
131
|
+
throw error;
|
|
132
|
+
throw new UsageError(safe(`The request failed: ${error.message}`));
|
|
133
|
+
}
|
|
134
|
+
const scrubbed = scrub(text, known);
|
|
135
|
+
if (scrubbed.problems.length > 0) {
|
|
136
|
+
throw new UsageError(`Refusing to print or record the output: ${scrubbed.problems.join("; ")}. Review it by hand.`);
|
|
137
|
+
}
|
|
138
|
+
let body;
|
|
139
|
+
try {
|
|
140
|
+
body = JSON.parse(scrubbed.text);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new UsageError("The runner's answer was not JSON.");
|
|
144
|
+
}
|
|
145
|
+
if (body.error)
|
|
146
|
+
throw new UsageError(`The runner refused: ${body.error}`);
|
|
147
|
+
console.log(JSON.stringify(body, null, 2));
|
|
148
|
+
let status = 0;
|
|
149
|
+
const expected = readExpect(probe.slug, config.probesDir);
|
|
150
|
+
if (expected !== undefined) {
|
|
151
|
+
if (matchesExpect(body.result, expected)) {
|
|
152
|
+
console.log("\nMatches the expected output.");
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
status = 2;
|
|
156
|
+
console.log(`\nDRIFT: the output differs from ${probe.slug}.expect.json. The platform changed, or the expectation is wrong.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (args.includes("--draft")) {
|
|
160
|
+
const probeFile = join(config.probesDir, `${probe.slug}.js`);
|
|
161
|
+
const page = join(config.resultsDir, `${probe.slug}.md`);
|
|
162
|
+
const shown = relative(project, page).replaceAll("\\", "/");
|
|
163
|
+
if (existsSync(page)) {
|
|
164
|
+
console.log(`\n${shown} already exists; not overwriting.`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const draft = scrub(resultsDraft({
|
|
168
|
+
date: new Date().toISOString().slice(0, 10),
|
|
169
|
+
output: body.result,
|
|
170
|
+
probe,
|
|
171
|
+
probeFile,
|
|
172
|
+
resultsDir: config.resultsDir,
|
|
173
|
+
scriptSource: read(probeFile),
|
|
174
|
+
version: body.version ?? "unknown",
|
|
175
|
+
}), known);
|
|
176
|
+
if (draft.problems.length > 0) {
|
|
177
|
+
throw new UsageError(`Refusing to write ${shown}: ${draft.problems.join("; ")}. Review it by hand.`);
|
|
178
|
+
}
|
|
179
|
+
writeFileSync(page, draft.text);
|
|
180
|
+
console.log(`\nWrote the draft ${shown}. Fill in each TODO(verify), then ask the user to review it.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return status;
|
|
184
|
+
}
|
package/dist/sdf.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileCabinetFolder } from "./netsuite.js";
|
|
4
|
+
import { isRestlet, listProbes, read, tagProblems, } from "./probes.js";
|
|
5
|
+
import { UsageError } from "./shared.js";
|
|
6
|
+
const templates = new URL("../templates/", import.meta.url);
|
|
7
|
+
const template = (name) => readFileSync(new URL(name, templates), "utf8").replace(/\r\n/g, "\n");
|
|
8
|
+
const q = (value) => JSON.stringify(value);
|
|
9
|
+
/** The runner source with its dependency list generated from the probe slugs. */
|
|
10
|
+
export function generateRunner(probes, source) {
|
|
11
|
+
const slugs = probes.map((probe) => probe.slug);
|
|
12
|
+
const names = slugs.map((_, i) => `probe${i}`);
|
|
13
|
+
// Each list item carries its own leading comma, so the template stays valid
|
|
14
|
+
// when the formatter moves the placeholder comments around.
|
|
15
|
+
return source
|
|
16
|
+
.replace("/*PROBE_PATHS*/", slugs.map((slug) => `, ${q(`./probes/${slug}`)}`).join(""))
|
|
17
|
+
.replace("/*PROBE_NAMES*/", names.map((name) => `, ${name}`).join(""))
|
|
18
|
+
.replace("/*PROBE_ENTRIES*/", slugs
|
|
19
|
+
.map((slug, i) => `[${q(slug)}, [${names[i]}, ${JSON.stringify(probes[i]?.params ?? [])}]],`)
|
|
20
|
+
.join(" "));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Writes the generated runner, the probe copies and the SDF project files into
|
|
24
|
+
* the SDF folder. Only RESTlet probes go in: the runner is a RESTlet. With
|
|
25
|
+
* `deployed: false` the runner's deployment is switched off (cleanup).
|
|
26
|
+
*/
|
|
27
|
+
export function buildSdfProject(sdfDir, probesDir, { deployed, extraTags = [], }) {
|
|
28
|
+
const probes = listProbes(probesDir, extraTags).filter(isRestlet);
|
|
29
|
+
const bad = probes.filter((probe) => tagProblems(probe).length > 0);
|
|
30
|
+
if (bad.length > 0) {
|
|
31
|
+
throw new UsageError(`Fix the probe tags before deploying:\n${bad
|
|
32
|
+
.flatMap((p) => tagProblems(p).map((m) => `- ${p.slug}: ${m}`))
|
|
33
|
+
.join("\n")}`);
|
|
34
|
+
}
|
|
35
|
+
const src = join(sdfDir, "src");
|
|
36
|
+
const folder = join(src, "FileCabinet", "SuiteScripts", fileCabinetFolder);
|
|
37
|
+
rmSync(join(src, "FileCabinet"), { force: true, recursive: true });
|
|
38
|
+
mkdirSync(join(folder, "probes"), { recursive: true });
|
|
39
|
+
mkdirSync(join(src, "Objects"), { recursive: true });
|
|
40
|
+
writeFileSync(join(sdfDir, "package.json"), template("sdf/package.json"));
|
|
41
|
+
writeFileSync(join(sdfDir, "suitecloud.config.js"), template("sdf/suitecloud.config.js"));
|
|
42
|
+
writeFileSync(join(src, "manifest.xml"), template("sdf/manifest.xml"));
|
|
43
|
+
writeFileSync(join(src, "deploy.xml"), template("sdf/deploy.xml"));
|
|
44
|
+
writeFileSync(join(src, "Objects", "customscript_suitescript_verify_runner.xml"), template("sdf/runner.xml").replace("%%DEPLOYED%%", deployed ? "T" : "F"));
|
|
45
|
+
const slugs = probes.map((probe) => probe.slug);
|
|
46
|
+
const files = ["runner.js"];
|
|
47
|
+
writeFileSync(join(folder, "runner.js"), generateRunner(probes, template("runner.template.js")));
|
|
48
|
+
for (const slug of slugs) {
|
|
49
|
+
writeFileSync(join(folder, "probes", `${slug}.js`), read(join(probesDir, `${slug}.js`)));
|
|
50
|
+
files.push(`probes/${slug}.js`);
|
|
51
|
+
}
|
|
52
|
+
return { files, slugs };
|
|
53
|
+
}
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { isSandboxAccount, names } from "./doctor.js";
|
|
6
|
+
import { applyEnvUpdate, planEnvUpdate } from "./envfile.js";
|
|
7
|
+
/** A refusal or mistake to report to the person; the message holds no secret. */
|
|
8
|
+
export class UsageError extends Error {
|
|
9
|
+
}
|
|
10
|
+
const valueFlags = ["--dir", "--keys-dir", "--only", "--param"];
|
|
11
|
+
/** The value after a flag, when given. */
|
|
12
|
+
export function flagValue(args, flag) {
|
|
13
|
+
const at = args.indexOf(flag);
|
|
14
|
+
if (at < 0)
|
|
15
|
+
return undefined;
|
|
16
|
+
const value = args[at + 1];
|
|
17
|
+
if (!value || value.startsWith("--")) {
|
|
18
|
+
throw new UsageError(`${flag} needs a value.`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
/** Arguments that are neither flags nor a flag's value. */
|
|
23
|
+
export function positionals(args) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
const arg = args[i] ?? "";
|
|
27
|
+
if (valueFlags.includes(arg))
|
|
28
|
+
i++;
|
|
29
|
+
else if (!arg.startsWith("--"))
|
|
30
|
+
out.push(arg);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** The project's own SuiteCloud CLI (a peer dependency), never a global one. */
|
|
35
|
+
function suitecloudEntry(project) {
|
|
36
|
+
const entry = join(project, "node_modules", "@oracle", "suitecloud-cli", "src", "suitecloud.js");
|
|
37
|
+
if (!existsSync(entry)) {
|
|
38
|
+
throw new UsageError("Install @oracle/suitecloud-cli in this project (npm install --save-dev @oracle/suitecloud-cli), and accept Oracle's license yourself.");
|
|
39
|
+
}
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Runs the project's SuiteCloud CLI from the SDF project folder. The package
|
|
44
|
+
* never sets SUITECLOUD_CLI_ACCEPT_LICENSE (ADR 0001): the child gets the
|
|
45
|
+
* parent's environment plus the passkey, nothing about the license.
|
|
46
|
+
*/
|
|
47
|
+
export function suitecloud(project, config, args) {
|
|
48
|
+
const env = { ...process.env, ...config.values };
|
|
49
|
+
// Hand the passkey to the CLI for this child process only.
|
|
50
|
+
if (env.SUITESCRIPT_VERIFY_PASSKEY) {
|
|
51
|
+
env.SUITECLOUD_CI_PASSKEY = env.SUITESCRIPT_VERIFY_PASSKEY;
|
|
52
|
+
}
|
|
53
|
+
if (env.SUITECLOUD_CI_PASSKEY)
|
|
54
|
+
env.SUITECLOUD_CI = "1";
|
|
55
|
+
const result = spawnSync(process.execPath, [suitecloudEntry(project), ...args], { cwd: config.sdfDir, env, stdio: "inherit" });
|
|
56
|
+
return result.status ?? 1;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A write to the sandbox needs a person at a terminal, on top of --approved:
|
|
60
|
+
* an agent's shell has none, so an agent cannot run it. The person types the
|
|
61
|
+
* action name to confirm.
|
|
62
|
+
*/
|
|
63
|
+
export async function confirmWrite(action, what) {
|
|
64
|
+
if (!process.stdin.isTTY) {
|
|
65
|
+
throw new UsageError(`${what} needs the user at a terminal. Ask the user to run this same command, with --approved, in their own terminal.`);
|
|
66
|
+
}
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
+
const answer = await rl.question(`${what}. Type ${action} to continue: `);
|
|
69
|
+
rl.close();
|
|
70
|
+
if (answer.trim() !== action) {
|
|
71
|
+
throw new UsageError("Not confirmed. Nothing was changed.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** True when the person (or --overwrite) allows replacing something that exists. */
|
|
75
|
+
export async function allowReplace(args, description) {
|
|
76
|
+
if (args.includes("--overwrite"))
|
|
77
|
+
return true;
|
|
78
|
+
if (args.includes("--keep") || !process.stdin.isTTY)
|
|
79
|
+
return false;
|
|
80
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
81
|
+
const answer = await rl.question(`${description} already exists. Overwrite it? [y/N] `);
|
|
82
|
+
rl.close();
|
|
83
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Saves values to `.env.local`. A name that already holds a different value is
|
|
87
|
+
* a conflict: ask the person (overwrite or keep). Without a terminal, or with
|
|
88
|
+
* --keep, keep it and say so; --overwrite replaces it. Values are never printed.
|
|
89
|
+
*/
|
|
90
|
+
export async function saveEnv(config, args, entries) {
|
|
91
|
+
const file = join(config.root, ".env.local");
|
|
92
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
93
|
+
const plan = planEnvUpdate(text, entries);
|
|
94
|
+
const overwrite = new Set();
|
|
95
|
+
const kept = [];
|
|
96
|
+
for (const name of plan.conflicts) {
|
|
97
|
+
if (await allowReplace(args, `${name} in .env.local`))
|
|
98
|
+
overwrite.add(name);
|
|
99
|
+
else
|
|
100
|
+
kept.push(name);
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
103
|
+
writeFileSync(file, applyEnvUpdate(text, entries, overwrite));
|
|
104
|
+
for (const name of [...plan.fresh, ...overwrite]) {
|
|
105
|
+
console.log(`Saved ${name} to .env.local.`);
|
|
106
|
+
}
|
|
107
|
+
for (const name of plan.same)
|
|
108
|
+
console.log(`${name} already has this value.`);
|
|
109
|
+
for (const name of kept) {
|
|
110
|
+
console.log(`Kept the existing ${name}. To replace it, ask the user, then rerun with --overwrite.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** The runner credentials, or a refusal: every command that reaches the sandbox needs a sandbox account. */
|
|
114
|
+
export function requireSandbox(config) {
|
|
115
|
+
const v = config.values;
|
|
116
|
+
const missing = Object.values(names).filter((name) => name !== names.passkey && !v[name]);
|
|
117
|
+
if (missing.length > 0) {
|
|
118
|
+
throw new UsageError(`Missing ${missing.join(", ")}. Run suitescript-verify doctor.`);
|
|
119
|
+
}
|
|
120
|
+
const account = v[names.account] ?? "";
|
|
121
|
+
if (!isSandboxAccount(account)) {
|
|
122
|
+
throw new UsageError(`${names.account} is not a sandbox account id (expected digits_SBn). Verification runs in a sandbox only.`);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
account,
|
|
126
|
+
certificateId: v[names.certificateId] ?? "",
|
|
127
|
+
clientId: v[names.clientId] ?? "",
|
|
128
|
+
privateKeyPath: v[names.privateKeyPath] ?? "",
|
|
129
|
+
sdfCertificateId: v[names.sdfCertificateId] ?? "",
|
|
130
|
+
sdfPrivateKeyPath: v[names.sdfPrivateKeyPath] ?? "",
|
|
131
|
+
};
|
|
132
|
+
}
|