suitescript-verify 0.0.0 → 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 CHANGED
@@ -4,8 +4,6 @@ Run probes in a NetSuite **sandbox** to verify what SuiteScript modules do, from
4
4
 
5
5
  > This package never accepts Oracle's SuiteCloud license. Install `@oracle/suitecloud-cli` in your project and accept the license yourself.
6
6
 
7
- Early version: `help`, `init`, `doctor` and `deploy` exist. The rest of the CLI (`certificate`, `passkey`, `env`, `setup-ci`, `run`, `cleanup`) is being extracted from an existing project.
8
-
9
7
  ```sh
10
8
  npm install --save-dev suitescript-verify @oracle/suitecloud-cli
11
9
  npx suitescript-verify help
@@ -13,7 +11,29 @@ npx suitescript-verify help
13
11
 
14
12
  `init` scaffolds `verification/probes`, `verification/sdf`, `docs/verification/results` and a committed `verification/.env` of layout keys, then prints a to-do list. It creates no credentials. `doctor` checks the SuiteCloud CLI, account, sandbox, certificates, passkey and project files, and says which failing steps are the maintainer's own; it exits 1 while any fails.
15
13
 
16
- `deploy` dry-runs the runner deployment through your project's SuiteCloud CLI from `verification/sdf` (`--dir <path>` moves `verification/`). A real deploy needs `--approved` and a person at a terminal who types `DEPLOY`.
14
+ `certificate` generates the runner key pair (EC P-256) and the SDF key pair (RSA-4096) outside the project with `openssl` (`--keys-dir`, default `~/.suitescript-verify`), `passkey` and `env set NAME=value` save settings in the gitignored `verification/.env.local` (values are never printed; a different existing value is kept unless `--overwrite`), and `setup-ci` registers the SDF credentials with the SuiteCloud CLI.
15
+
16
+ `setup-ci` prepares a pipeline: it registers the SDF credentials (auth id `suitescript-verify`) with your project's SuiteCloud CLI and passes the passkey to it through the environment, never an argument. On success it prints the `SUITESCRIPT_VERIFY_` names the pipeline must supply as its own secrets (account, client id, both certificate ids, both private key paths, passkey), never a value. It writes no file, no credential variable and no license variable, and it holds no credential itself: the pipeline provides them. Accepting Oracle's SuiteCloud license in the pipeline is yours.
17
+
18
+ `deploy` generates the runner and an SDF project into `verification/sdf` (the generated `src/` and `package.json` there are yours to gitignore) from the RESTlet probes in `verification/probes`, then dry-runs it through your project's SuiteCloud CLI. A real deploy needs `--approved` and a person at a terminal who types `DEPLOY`. `cleanup` does the same with the runner's deployment switched off and needs `CLEANUP`. `--dir <path>` moves `verification/`.
19
+
20
+ `run <probe> [--draft]` runs one probe by name through the deployed runner. The name must be one of the probes in the folder: the runner only runs the fixed list generated into it and never code from a request. Output is scrubbed of the account, client and certificate ids, and refused if it still looks like an identifier. A probe with an `<probe>.expect.json` is compared with it and a difference exits 2 as drift. `--draft` writes `docs/verification/results/<probe>.md` (never over an existing page) with the test, NetSuite version and results, and empty explanation and decision sections. Probes must declare `@scriptType Restlet` and no `@writes`.
21
+
22
+ A probe takes data by declaring parameters in its leading JSDoc block, one tag per parameter: `@param <name> <pattern>`. The name is lowercase-initial letters and digits (`recordType`); the pattern is a JavaScript regular expression (Unicode mode) that the whole value must match, so `[a-z_]+` cannot match `a;b`. Pass each value with `run <probe> --param name=value`. Every declared parameter is required and its value is always a string. The runner validates the parameters against the declarations before any request, rejecting an invalid, undeclared, missing or repeated one, and a probe that declares none takes none; the generated runner checks them again and hands the probe only declared strings in `run(params)`. A value goes in the JSON request body and is never evaluated or turned into code. A bad `@param` tag (no pattern, an invalid pattern, a duplicate name) stops `run` and `deploy`.
23
+
24
+ The package validates the tags in a probe's leading JSDoc block: `@scriptType`, `@writes`, `@param`, the NetSuite `@NApiVersion`, `@NScriptType` and `@NModuleScope`, and the ordinary `@see`, `@link`, `@example`, `@returns`, `@since` and `@deprecated`. Any other tag is reported and stops `run` and `deploy`, so a typo cannot pass silently. A project that keeps its own tags registers them with `SUITESCRIPT_VERIFY_EXTRA_TAGS`, a comma-separated list of tag names without the `@` (for example `SUITESCRIPT_VERIFY_EXTRA_TAGS=flavor,other`). Like every `SUITESCRIPT_VERIFY_` setting it layers from the committed `verification/.env`, the gitignored `.env.local` and the process environment. In the committed file the value must be a list of tag names and must not look like a secret. The package checks only that a registered tag is known; what a tag means is up to the project.
25
+
26
+ ```js
27
+ /**
28
+ * @scriptType Restlet
29
+ * @param recordType ^[a-z_]+$
30
+ */
31
+ define(["N/record"], (record) => ({
32
+ run: ({ recordType }) => /* ... */,
33
+ }));
34
+ ```
35
+
36
+ Every command that reaches the sandbox refuses an account that is not a sandbox (`digits_SBn`). The runner uses the script id `customscript_suitescript_verify_runner` and is limited to a role with the ID `customrole_suitescript_verify`, which you create in the sandbox.
17
37
 
18
38
  See [docs/verification/README.md](docs/verification/README.md) and [docs/verification/lessons.md](docs/verification/lessons.md).
19
39
 
@@ -0,0 +1,116 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
5
+ import { names } from "./doctor.js";
6
+ import { allowReplace, flagValue, saveEnv, UsageError } from "./shared.js";
7
+ /** Oracle's documented `openssl` commands: ES256 (EC P-256) and RSA-PSS (RSA-4096, PS256). */
8
+ function generatePair(options) {
9
+ const keyArgs = options.kind === "ec"
10
+ ? [
11
+ "-newkey",
12
+ "ec",
13
+ "-pkeyopt",
14
+ "ec_paramgen_curve:prime256v1",
15
+ "-pkeyopt",
16
+ "ec_param_enc:named_curve",
17
+ ]
18
+ : [
19
+ "-newkey",
20
+ "rsa:4096",
21
+ "-sigopt",
22
+ "rsa_padding_mode:pss",
23
+ "-sha256",
24
+ "-sigopt",
25
+ "rsa_pss_saltlen:64",
26
+ ];
27
+ const result = spawnSync(options.openssl, [
28
+ "req",
29
+ "-new",
30
+ "-x509",
31
+ ...keyArgs,
32
+ "-nodes",
33
+ "-days",
34
+ "365",
35
+ "-subj",
36
+ "/CN=suitescript-verify",
37
+ "-out",
38
+ options.publicKey,
39
+ "-keyout",
40
+ options.privateKey,
41
+ ], { encoding: "utf8" });
42
+ if (result.status !== 0) {
43
+ throw new UsageError("openssl failed to generate the key pair.");
44
+ }
45
+ try {
46
+ chmodSync(options.privateKey, 0o600);
47
+ }
48
+ catch {
49
+ // Windows ignores POSIX modes.
50
+ }
51
+ if (process.platform === "win32" && process.env.USERNAME) {
52
+ spawnSync("icacls", [
53
+ options.privateKey,
54
+ "/inheritance:r",
55
+ "/grant:r",
56
+ `${process.env.USERNAME}:F`,
57
+ ]);
58
+ }
59
+ }
60
+ /** Generates the runner (EC P-256) and SDF (RSA-4096) key pairs outside the project and saves their paths in .env.local. */
61
+ export async function certificate(project, config, args) {
62
+ const dir = resolve(flagValue(args, "--keys-dir") ?? join(homedir(), ".suitescript-verify"));
63
+ const rel = relative(project, dir);
64
+ if (rel === "" || !(rel.startsWith("..") || isAbsolute(rel))) {
65
+ throw new UsageError("Choose a --keys-dir outside the project.");
66
+ }
67
+ const openssl = [
68
+ "openssl",
69
+ ...(process.platform === "win32"
70
+ ? ["C:\\Program Files\\Git\\usr\\bin\\openssl.exe"]
71
+ : []),
72
+ ].find((candidate) => spawnSync(candidate, ["version"], { encoding: "utf8" }).status === 0);
73
+ if (!openssl) {
74
+ throw new UsageError("openssl was not found. Generate the key pairs by hand with Oracle's documented openssl commands.");
75
+ }
76
+ // The runner token is signed here (ES256, EC P-256). Oracle's SDK signs the
77
+ // SDF login with PS256 and an RSA key only, so that pair is RSA-4096.
78
+ const kinds = { runner: "ec", sdf: "rsa" };
79
+ const only = flagValue(args, "--only");
80
+ if (only !== undefined && only !== "runner" && only !== "sdf") {
81
+ throw new UsageError("--only takes runner or sdf.");
82
+ }
83
+ const which = ["runner", "sdf"].filter((name) => only === undefined || only === name);
84
+ const paths = (name) => ({
85
+ privateKey: join(dir, `${name}-private.pem`),
86
+ publicKey: join(dir, `${name}-public.pem`),
87
+ });
88
+ mkdirSync(dir, { mode: 0o700, recursive: true });
89
+ for (const name of which) {
90
+ const pair = paths(name);
91
+ const exists = existsSync(pair.privateKey) || existsSync(pair.publicKey);
92
+ const mapped = config.values[name === "runner" ? names.certificateId : names.sdfCertificateId];
93
+ if (exists && mapped) {
94
+ console.log(`Warning: a ${name} certificate id is already saved. A mapping cannot be recreated with the same certificate, and replacing this pair makes that mapping unusable (its private key is lost).`);
95
+ }
96
+ if (!exists || (await allowReplace(args, `The ${name} key pair`))) {
97
+ generatePair({ kind: kinds[name], openssl, ...pair });
98
+ console.log(`Generated the ${name} key pair (${kinds[name] === "ec" ? "EC P-256" : "RSA-4096, RSA-PSS certificate"}, valid for 365 days).`);
99
+ }
100
+ else
101
+ console.log(`Kept the existing ${name} key pair.`);
102
+ }
103
+ await saveEnv(config, args, {
104
+ ...(which.includes("runner")
105
+ ? { [names.privateKeyPath]: paths("runner").privateKey }
106
+ : {}),
107
+ ...(which.includes("sdf")
108
+ ? { [names.sdfPrivateKeyPath]: paths("sdf").privateKey }
109
+ : {}),
110
+ });
111
+ console.log([
112
+ "",
113
+ "Upload these public certificates in your sandbox (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup):",
114
+ ...which.map((name) => ` ${name}: ${paths(name).publicKey}`),
115
+ ].join("\n"));
116
+ }
package/dist/cli.js CHANGED
@@ -1,68 +1,33 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { createInterface } from "node:readline/promises";
1
+ import { randomBytes } from "node:crypto";
2
+ import { certificate } from "./certificate.js";
5
3
  import { loadConfig } from "./config.js";
6
- import { formatChecks, runChecks } from "./doctor.js";
4
+ import { formatChecks, names, runChecks } from "./doctor.js";
5
+ import { envNames } from "./envfile.js";
7
6
  import { init } from "./init.js";
7
+ import { runProbe } from "./run.js";
8
+ import { buildSdfProject } from "./sdf.js";
9
+ import { confirmWrite, flagValue, positionals, requireSandbox, saveEnv, suitecloud, UsageError, } from "./shared.js";
8
10
  export const USAGE = `Usage: suitescript-verify <command> [options]
9
11
 
10
12
  Commands:
11
- deploy [--approved] [--dir <path>] Dry-run the runner deployment; --approved deploys, from your own terminal
12
- doctor [--dir <path>] Check the setup; each failing step says who does it
13
- init [--dir <path>] Scaffold the folders and .env, and print a to-do list
13
+ init Scaffold the folders and .env, and print a to-do list
14
+ doctor Check the setup; each failing step says who does it
15
+ certificate [--only runner|sdf] [--keys-dir <path>] [--overwrite | --keep]
16
+ Generate the key pairs outside the project
17
+ passkey [--overwrite | --keep] Generate the SuiteCloud passkey into .env.local
18
+ env set NAME=value [--overwrite | --keep]
19
+ Save a setting in .env.local
20
+ setup-ci Register the SDF credentials (account:setup:ci)
21
+ deploy [--approved] Dry-run the runner deployment; --approved deploys, from your own terminal
22
+ run <probe> [--param name=value ...] [--draft] Run one RESTlet probe through the runner; --draft writes a results page
23
+ cleanup [--approved] Turn the runner's deployment off (dry run without --approved)
14
24
  help Show this help
15
25
 
26
+ Every command takes --dir <path> to move the verification folder (default verification).
27
+
16
28
  Probes run in a NetSuite sandbox only. The package never accepts Oracle's SuiteCloud license:
17
29
  install @oracle/suitecloud-cli in your project and accept the license yourself.`;
18
- class UsageError extends Error {
19
- }
20
- /** The project's own SuiteCloud CLI (a peer dependency), never a global one. */
21
- function suitecloudEntry(project) {
22
- const entry = join(project, "node_modules", "@oracle", "suitecloud-cli", "src", "suitecloud.js");
23
- if (!existsSync(entry)) {
24
- throw new UsageError("Install @oracle/suitecloud-cli in this project (npm install --save-dev @oracle/suitecloud-cli), and accept Oracle's license yourself.");
25
- }
26
- return entry;
27
- }
28
- /** Runs the project's SuiteCloud CLI from the SDF project folder. */
29
- function suitecloud(project, sdfDir, args, layered) {
30
- const env = { ...process.env, ...layered };
31
- // Hand the passkey to the CLI for this child process only.
32
- if (env.SUITESCRIPT_VERIFY_PASSKEY) {
33
- env.SUITECLOUD_CI_PASSKEY = env.SUITESCRIPT_VERIFY_PASSKEY;
34
- }
35
- if (env.SUITECLOUD_CI_PASSKEY)
36
- env.SUITECLOUD_CI = "1";
37
- const result = spawnSync(process.execPath, [suitecloudEntry(project), ...args], { cwd: sdfDir, env, stdio: "inherit" });
38
- return result.status ?? 1;
39
- }
40
- /**
41
- * A write to the sandbox needs a person at a terminal, on top of --approved:
42
- * an agent's shell has none, so an agent cannot run it. The person types the
43
- * action name to confirm.
44
- */
45
- async function confirmWrite(action, what) {
46
- if (!process.stdin.isTTY) {
47
- throw new UsageError(`${what} needs the user at a terminal. Ask the user to run this same command, with --approved, in their own terminal.`);
48
- }
49
- const rl = createInterface({ input: process.stdin, output: process.stdout });
50
- const answer = await rl.question(`${what}. Type ${action} to continue: `);
51
- rl.close();
52
- if (answer.trim() !== action) {
53
- throw new UsageError("Not confirmed. Nothing was changed.");
54
- }
55
- }
56
- /** The value after `--dir`, when given. */
57
- function dirFlag(args) {
58
- const flag = args.indexOf("--dir");
59
- if (flag < 0)
60
- return undefined;
61
- const value = args[flag + 1];
62
- if (!value)
63
- throw new UsageError("--dir needs a path.");
64
- return value;
65
- }
30
+ const dirFlag = (args) => flagValue(args, "--dir");
66
31
  /** Loads the layered configuration; a problem stops every command except the reporting ones. */
67
32
  function configure(args, refuse) {
68
33
  const config = loadConfig(process.cwd(), dirFlag(args));
@@ -95,13 +60,96 @@ function initCommand(args) {
95
60
  async function deploy(args) {
96
61
  const project = process.cwd();
97
62
  const config = configure(args, true);
63
+ requireSandbox(config);
64
+ const { files, slugs } = buildSdfProject(config.sdfDir, config.probesDir, {
65
+ deployed: true,
66
+ extraTags: config.extraTags,
67
+ });
68
+ console.log(`Probes in the runner: ${slugs.join(", ") || "(none)"}`);
69
+ console.log(`Files to upload: ${files.join(", ")}`);
98
70
  const approved = args.includes("--approved");
99
71
  if (approved)
100
72
  await confirmWrite("DEPLOY", "This deploys to the sandbox");
101
73
  else {
102
- console.log("Dry run only. Deploying writes to the sandbox, so ask the user for approval, then rerun with --approved.");
74
+ console.log("\nDry run only. Deploying writes to the sandbox, so ask the user for approval, then rerun with --approved.");
75
+ }
76
+ return suitecloud(project, config, [
77
+ "project:deploy",
78
+ ...(approved ? [] : ["--dryrun"]),
79
+ ]);
80
+ }
81
+ async function cleanup(args) {
82
+ const project = process.cwd();
83
+ const config = configure(args, true);
84
+ requireSandbox(config);
85
+ const approved = args.includes("--approved");
86
+ if (approved) {
87
+ await confirmWrite("CLEANUP", "This turns the runner's deployment off");
88
+ }
89
+ else {
90
+ console.log("Dry run only. This turns the runner's deployment off in the sandbox, so ask the user for approval, then rerun with --approved.\nTo remove access entirely, the user revokes the certificate mapping (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup).");
103
91
  }
104
- return suitecloud(project, config.sdfDir, ["project:deploy", ...(approved ? [] : ["--dryrun"])], config.values);
92
+ buildSdfProject(config.sdfDir, config.probesDir, {
93
+ deployed: false,
94
+ extraTags: config.extraTags,
95
+ });
96
+ return suitecloud(project, config, [
97
+ "project:deploy",
98
+ ...(approved ? [] : ["--dryrun"]),
99
+ ]);
100
+ }
101
+ const CI_SETTINGS = [
102
+ "ACCOUNT",
103
+ "CLIENT_ID",
104
+ "CERTIFICATE_ID",
105
+ "PRIVATE_KEY_PATH",
106
+ "SDF_CERTIFICATE_ID",
107
+ "SDF_PRIVATE_KEY_PATH",
108
+ "PASSKEY",
109
+ ].map((name) => ` SUITESCRIPT_VERIFY_${name}`);
110
+ const CI_NOTES = [
111
+ "",
112
+ "Registered the SDF credentials with the SuiteCloud CLI. To run probes from a pipeline, the pipeline supplies these settings as its own secrets and variables (names only; this command wrote none and holds no value):",
113
+ ...CI_SETTINGS,
114
+ "Accept Oracle's SuiteCloud license yourself in the pipeline; this package never does.",
115
+ ].join("\n");
116
+ function setupCi(args) {
117
+ const config = configure(args, true);
118
+ const creds = requireSandbox(config);
119
+ const status = suitecloud(process.cwd(), config, [
120
+ "account:setup:ci",
121
+ "--account",
122
+ creds.account,
123
+ "--authid",
124
+ "suitescript-verify",
125
+ "--certificateid",
126
+ creds.sdfCertificateId,
127
+ "--privatekeypath",
128
+ creds.sdfPrivateKeyPath,
129
+ ...(config.values.SUITESCRIPT_VERIFY_DOMAIN
130
+ ? ["--domain", config.values.SUITESCRIPT_VERIFY_DOMAIN]
131
+ : []),
132
+ ]);
133
+ if (status === 0)
134
+ console.log(CI_NOTES);
135
+ return status;
136
+ }
137
+ async function envCommand(args) {
138
+ const config = configure(args, true);
139
+ const [action, assignment] = positionals(args);
140
+ const match = /^([A-Z0-9_]+)=([\s\S]*)$/.exec(assignment ?? "");
141
+ if (action !== "set" || !match) {
142
+ throw new UsageError("Usage: suitescript-verify env set NAME=value [--overwrite | --keep]");
143
+ }
144
+ const name = match[1] ?? "";
145
+ if (name === names.passkey) {
146
+ throw new UsageError("Generate the passkey with `suitescript-verify passkey`.");
147
+ }
148
+ if (!envNames.includes(name)) {
149
+ throw new UsageError(`Unknown variable ${name}. Known: ${envNames.join(", ")}`);
150
+ }
151
+ await saveEnv(config, args, { [name]: match[2] ?? "" });
152
+ return 0;
105
153
  }
106
154
  export async function main(args) {
107
155
  const [command, ...rest] = args;
@@ -112,6 +160,26 @@ export async function main(args) {
112
160
  }
113
161
  if (command === "deploy")
114
162
  return await deploy(rest);
163
+ if (command === "cleanup")
164
+ return await cleanup(rest);
165
+ if (command === "run") {
166
+ const config = configure(rest, true);
167
+ return await runProbe(process.cwd(), config, rest);
168
+ }
169
+ if (command === "certificate") {
170
+ await certificate(process.cwd(), configure(rest, true), rest);
171
+ return 0;
172
+ }
173
+ if (command === "passkey") {
174
+ await saveEnv(configure(rest, true), rest, {
175
+ [names.passkey]: randomBytes(30).toString("hex"),
176
+ });
177
+ return 0;
178
+ }
179
+ if (command === "env")
180
+ return await envCommand(rest);
181
+ if (command === "setup-ci")
182
+ return setupCi(rest);
115
183
  if (command === "doctor")
116
184
  return doctor(rest);
117
185
  if (command === "init")
package/dist/config.js CHANGED
@@ -8,6 +8,14 @@ export const layoutKeys = [
8
8
  "SUITESCRIPT_VERIFY_RESULTS_DIR",
9
9
  "SUITESCRIPT_VERIFY_SDF_DIR",
10
10
  ];
11
+ /** The committed `.env` may also register extra probe tags: a comma-separated list of tag names, not a path. */
12
+ export const extraTagsKey = "SUITESCRIPT_VERIFY_EXTRA_TAGS";
13
+ const tagName = /^[A-Za-z][A-Za-z0-9_-]*$/;
14
+ /** The tag names in a registration value, without the `@`; blank entries are dropped. */
15
+ export const parseTagList = (value) => value
16
+ .split(",")
17
+ .map((name) => name.trim().replace(/^@/, ""))
18
+ .filter((name) => name !== "");
11
19
  const unquote = (raw) => {
12
20
  const value = raw.trim();
13
21
  const quote = value[0];
@@ -45,9 +53,21 @@ const looksSecret = (value) => /-----BEGIN /.test(value) ||
45
53
  function checkCommitted(project, committed, file) {
46
54
  const problems = [];
47
55
  for (const [key, value] of committed) {
48
- if (!layoutKeys.includes(key)) {
56
+ if (key === extraTagsKey) {
57
+ if (!parseTagList(value).every((name) => tagName.test(name))) {
58
+ problems.push({
59
+ message: `${key} in ${file} is not a comma-separated list of tag names like flavor,other.`,
60
+ });
61
+ }
62
+ else if (looksSecret(value)) {
63
+ problems.push({
64
+ message: `${key} in ${file} looks like a secret, not a list of tag names. The committed file is public: move a secret to .env.local.`,
65
+ });
66
+ }
67
+ }
68
+ else if (!layoutKeys.includes(key)) {
49
69
  problems.push({
50
- message: `${key} in ${file} is not a layout key. The committed file holds layout only (${layoutKeys.join(", ")}); put anything else in .env.local or the process environment.`,
70
+ message: `${key} in ${file} is not a layout key. The committed file holds layout only (${[...layoutKeys, extraTagsKey].join(", ")}); put anything else in .env.local or the process environment.`,
51
71
  });
52
72
  }
53
73
  else if (!insideProject(project, value)) {
@@ -107,6 +127,7 @@ export function loadConfig(project, dir, processEnv = process.env) {
107
127
  const path = (key, fallback) => resolve(project, values[key] || fallback);
108
128
  const shown = relative(project, envFile).replaceAll("\\", "/");
109
129
  return {
130
+ extraTags: parseTagList(values[extraTagsKey] ?? ""),
110
131
  probesDir: path("SUITESCRIPT_VERIFY_PROBES_DIR", join(root, "probes")),
111
132
  problems: [
112
133
  ...checkCommitted(project, committed, shown),
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createPrivateKey } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- const names = {
4
+ export const names = {
5
5
  account: "SUITESCRIPT_VERIFY_ACCOUNT",
6
6
  certificateId: "SUITESCRIPT_VERIFY_CERTIFICATE_ID",
7
7
  clientId: "SUITESCRIPT_VERIFY_CLIENT_ID",
@@ -26,7 +26,7 @@ function keyType(path) {
26
26
  return "other";
27
27
  }
28
28
  /** A sandbox account id looks like `1234567_SB1`. Verification never runs in production. */
29
- export const isSandboxAccount = (account) => /^\d+_SB\d*$/i.test(account);
29
+ export const isSandboxAccount = (account) => /^\d{5,8}_SB\d+$/i.test(account);
30
30
  /** Read-only checklist: each failing step says what to do next and who does it. */
31
31
  export function runChecks(project, config, env = process.env) {
32
32
  const checks = [];
@@ -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
+ }
@@ -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
+ ];
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suitescript-verify",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "Run probes in a NetSuite sandbox to verify what SuiteScript modules do, with guardrails for agents.",
5
5
  "keywords": [
6
6
  "suitescript",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "dist",
26
+ "templates",
26
27
  "README.md",
27
28
  "LICENSE"
28
29
  ],
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The verification runner: a RESTlet that runs one probe by name and returns
3
+ * what it found. It never runs code from the request: the probes below are
4
+ * the only code it can run, and they are generated from the probes folder
5
+ * into this file's dependency list by `suitescript-verify deploy`.
6
+ *
7
+ * It refuses to run unless the account is a sandbox.
8
+ *
9
+ * @NApiVersion 2.1
10
+ * @NScriptType Restlet
11
+ */
12
+ define(["N/runtime" /*PROBE_PATHS*/], (runtime /*PROBE_NAMES*/) => {
13
+ const probes = new Map([
14
+ /*PROBE_ENTRIES*/
15
+ ]);
16
+
17
+ return {
18
+ post(body) {
19
+ if (runtime.envType !== runtime.EnvType.SANDBOX) {
20
+ return { error: "refused: the runner only runs in a sandbox" };
21
+ }
22
+ const name = body && typeof body.probe === "string" ? body.probe : "";
23
+ const entry = probes.get(name);
24
+ if (!entry) {
25
+ return { error: "unknown probe", known: [...probes.keys()] };
26
+ }
27
+ const [probe, declared] = entry;
28
+ // Parameters are data: only declared names, only strings that match the
29
+ // declared pattern in full, and never evaluated.
30
+ const sent = body.params === undefined ? {} : body.params;
31
+ if (!sent || typeof sent !== "object" || Array.isArray(sent)) {
32
+ return { error: "invalid params: expected an object" };
33
+ }
34
+ const params = {};
35
+ for (const key of Object.keys(sent)) {
36
+ if (!declared.some((param) => param.name === key)) {
37
+ return { error: `undeclared param: ${key}` };
38
+ }
39
+ }
40
+ for (const { name: key, pattern } of declared) {
41
+ const value = sent[key];
42
+ if (typeof value !== "string") {
43
+ return { error: `missing or non-string param: ${key}` };
44
+ }
45
+ if (!new RegExp(`^(?:${pattern})$`, "u").test(value)) {
46
+ return { error: `param does not match its pattern: ${key}` };
47
+ }
48
+ params[key] = value;
49
+ }
50
+ let result;
51
+ try {
52
+ result = probe.run(params);
53
+ } catch (e) {
54
+ result = `THROWS: ${e.name}: ${e.message}`;
55
+ }
56
+ return {
57
+ probe: name,
58
+ result,
59
+ scriptType: "RESTlet",
60
+ version: String(runtime.version),
61
+ };
62
+ },
63
+ };
64
+ });
@@ -0,0 +1,8 @@
1
+ <deploy>
2
+ <files>
3
+ <path>~/FileCabinet/SuiteScripts/suitescript-verify/*</path>
4
+ </files>
5
+ <objects>
6
+ <path>~/Objects/*</path>
7
+ </objects>
8
+ </deploy>
@@ -0,0 +1,12 @@
1
+ <manifest projecttype="ACCOUNTCUSTOMIZATION">
2
+ <projectname>suitescript-verify</projectname>
3
+ <frameworkversion>1.0</frameworkversion>
4
+ <dependencies>
5
+ <features>
6
+ <feature required="true">SERVERSIDESCRIPTING</feature>
7
+ </features>
8
+ <objects>
9
+ <object>customrole_suitescript_verify</object>
10
+ </objects>
11
+ </dependencies>
12
+ </manifest>
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "suitescript-verify-sdf",
3
+ "private": true,
4
+ "type": "commonjs"
5
+ }
@@ -0,0 +1,17 @@
1
+ <restlet scriptid="customscript_suitescript_verify_runner">
2
+ <description>Runs one version-controlled probe by name in a sandbox. Source: the project's verification folder, through suitescript-verify.</description>
3
+ <isinactive>F</isinactive>
4
+ <name>suitescript-verify runner</name>
5
+ <notifyowner>F</notifyowner>
6
+ <scriptfile>[/SuiteScripts/suitescript-verify/runner.js]</scriptfile>
7
+ <scriptdeployments>
8
+ <scriptdeployment scriptid="customdeploy_suitescript_verify_runner">
9
+ <allroles>F</allroles>
10
+ <audslctrole>[customrole_suitescript_verify]</audslctrole>
11
+ <isdeployed>%%DEPLOYED%%</isdeployed>
12
+ <loglevel>DEBUG</loglevel>
13
+ <status>RELEASED</status>
14
+ <title>suitescript-verify runner</title>
15
+ </scriptdeployment>
16
+ </scriptdeployments>
17
+ </restlet>
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ commands: {},
3
+ defaultProjectFolder: "src",
4
+ };