underpost 3.2.28 → 3.2.70

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/src/cli/deploy.js CHANGED
@@ -383,6 +383,10 @@ spec:
383
383
  * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
384
384
  * @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
385
385
  * @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes (migration path).
386
+ * @param {string} [options.node] - Explicit target node for hostPath PV nodeAffinity pinning; resolved through {@link UnderpostDeploy.resolveDeployNode} together with the cluster flags.
387
+ * @param {boolean} [options.kind] - Kind cluster context; affects the cluster-type node default when no explicit node is set.
388
+ * @param {boolean} [options.kubeadm] - Kubeadm cluster context; affects the cluster-type node default when no explicit node is set.
389
+ * @param {boolean} [options.k3s] - K3s cluster context; affects the cluster-type node default when no explicit node is set.
386
390
  * @returns {Promise<void>} - Promise that resolves when the manifest is built.
387
391
  * @memberof UnderpostDeploy
388
392
  */
@@ -455,6 +459,15 @@ ${Underpost.deploy
455
459
  ? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
456
460
  : [];
457
461
  if (confVolume.length > 0) {
462
+ // Mirror deployVolume's data-node resolution so the generated manifest
463
+ // pins the PV to the same node that physically receives the volume data.
464
+ const pvDataNode = Underpost.deploy.resolveDeployNode({
465
+ node: options.node,
466
+ kind: options.kind,
467
+ kubeadm: options.kubeadm,
468
+ k3s: options.k3s,
469
+ env,
470
+ });
458
471
  let volumeYaml = '';
459
472
  for (const deploymentVersion of deploymentVersions) {
460
473
  for (const volume of confVolume) {
@@ -466,6 +479,7 @@ ${Underpost.deploy
466
479
  pvcId,
467
480
  namespace: options.namespace,
468
481
  hostPath,
482
+ nodeName: pvDataNode,
469
483
  })}\n`;
470
484
  }
471
485
  }
@@ -548,7 +562,7 @@ ${Underpost.deploy
548
562
  const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
549
563
  fs.writeFileSync(yamlPath, secretYaml, 'utf8');
550
564
  } else {
551
- const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml'];
565
+ const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml', 'grpc-service.yaml'];
552
566
  for (const file of deploymentsFiles) {
553
567
  if (fs.existsSync(`./engine-private/conf/${deployId}/build/${env}/${file}`)) {
554
568
  fs.copyFileSync(
@@ -720,7 +734,8 @@ spec:
720
734
  * @param {string} options.image - Docker image for the deployment.
721
735
  * @param {string} options.traffic - Traffic status for the deployment.
722
736
  * @param {string} options.replicas - Number of replicas for the deployment.
723
- * @param {string} options.node - Node name for resource allocation.
737
+ * @param {string} options.node - Explicit target node (highest precedence in the node chain). When empty, {@link UnderpostDeploy.resolveDeployNode} falls back to the cluster-type default (`kind-worker` for kind, host for kubeadm/k3s). Used for both volume placement and hostPath PV nodeAffinity.
738
+ * @param {string} [options.sshKeyPath] - Private key path for node SSH operations, forwarded to deployVolume when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.
724
739
  * @param {boolean} options.disableUpdateDeployment - Whether to disable deployment updates.
725
740
  * @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
726
741
  * @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
@@ -938,9 +953,16 @@ EOF`);
938
953
  env,
939
954
  version,
940
955
  namespace,
941
- nodeName: options.node ? options.node : env === 'development' ? 'kind-worker' : os.hostname(),
956
+ nodeName: Underpost.deploy.resolveDeployNode({
957
+ node: options.node,
958
+ kind: options.kind,
959
+ kubeadm: options.kubeadm,
960
+ k3s: options.k3s,
961
+ env,
962
+ }),
942
963
  clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
943
964
  gitClean: options.gitClean || false,
965
+ sshKeyPath: options.sshKeyPath || '',
944
966
  });
945
967
  }
946
968
 
@@ -987,10 +1009,17 @@ EOF`);
987
1009
  */
988
1010
  configMap(env, namespace = 'default') {
989
1011
  const cronDeployId = cronDeployIdResolve() || 'dd-cron';
1012
+ const envFilePath = `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env}`;
1013
+ // `--from-env-file` turns every KEY=VALUE into a secret key that the Deployment injects via
1014
+ // `envFrom`. Strip shell/runtime-critical keys (notably PATH) first — an injected PATH
1015
+ // overrides the image's own and breaks coreutils/sudo resolution inside the pod.
1016
+ const sanitizedEnvPath = `${envFilePath}.secret`;
1017
+ fs.writeFileSync(sanitizedEnvPath, Underpost.secret.sanitizeSecretEnvFile(fs.readFileSync(envFilePath, 'utf8')));
990
1018
  shellExec(`kubectl delete secret underpost-config -n ${namespace} --ignore-not-found`);
991
1019
  shellExec(
992
- `kubectl create secret generic underpost-config --from-env-file=/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
1020
+ `kubectl create secret generic underpost-config --from-env-file=${sanitizedEnvPath} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
993
1021
  );
1022
+ fs.removeSync(sanitizedEnvPath);
994
1023
  },
995
1024
  /**
996
1025
  * Switches the traffic for a deployment.
@@ -1036,6 +1065,37 @@ EOF`);
1036
1065
  Underpost.env.set(`${deployId}-${env}-traffic`, targetTraffic);
1037
1066
  },
1038
1067
 
1068
+ /**
1069
+ * Resolves the effective target node for a deployment, applying a single
1070
+ * precedence chain shared by every deploy workflow — the default `deploy`
1071
+ * callback, `run sync`, and custom `run instance` — so node customization
1072
+ * behaves identically everywhere:
1073
+ *
1074
+ * 1. **Explicit node** — `node` (the resolved `--node` value). Upstream
1075
+ * runners derive it from the comma-path field or `--node-name`
1076
+ * (`run sync`: `path.split(',')[4]` > `--node-name` > default) and from
1077
+ * `--node-name` directly (`run instance`).
1078
+ * 2. **Cluster-type default** — when no explicit node is given: `kind-worker`
1079
+ * for a kind cluster (the node that hosts kind hostPath volumes),
1080
+ * otherwise the control-plane / current host (`os.hostname()`) for
1081
+ * kubeadm / k3s. With no explicit cluster flag, `development` is treated
1082
+ * as kind and `production` as the host, preserving legacy behaviour.
1083
+ *
1084
+ * @param {object} params
1085
+ * @param {string} [params.node=''] - Explicit node (`--node`); highest precedence.
1086
+ * @param {boolean} [params.kind=false] - Kind cluster context.
1087
+ * @param {boolean} [params.kubeadm=false] - Kubeadm cluster context.
1088
+ * @param {boolean} [params.k3s=false] - K3s cluster context.
1089
+ * @param {string} [params.env=''] - Deployment environment; tie-breaker when no cluster flag is set.
1090
+ * @returns {string} The effective node name.
1091
+ * @memberof UnderpostDeploy
1092
+ */
1093
+ resolveDeployNode({ node = '', kind = false, kubeadm = false, k3s = false, env = '' } = {}) {
1094
+ if (node) return node;
1095
+ const isKind = kind || (!kubeadm && !k3s && env !== 'production');
1096
+ return isKind ? 'kind-worker' : os.hostname();
1097
+ },
1098
+
1039
1099
  /**
1040
1100
  * Deploys a volume for a deployment.
1041
1101
  * @param {object} volume - Volume configuration.
@@ -1047,9 +1107,10 @@ EOF`);
1047
1107
  * @param {string} options.env - Environment for the deployment.
1048
1108
  * @param {string} options.version - Version of the deployment.
1049
1109
  * @param {string} options.namespace - Kubernetes namespace for the deployment.
1050
- * @param {string} options.nodeName - Node name for the deployment.
1110
+ * @param {string} options.nodeName - Effective target node (already resolved via {@link UnderpostDeploy.resolveDeployNode}). The volume data is written/shipped here and the PV is pinned to it; an empty value falls back to the cluster-type default inside this method.
1051
1111
  * @param {string} [options.clusterContext='kind'] - Cluster context type ('kind', 'kubeadm', or 'k3s').
1052
1112
  * @param {boolean} [options.gitClean=false] - Whether to run git clean on volumeMountPath before copying.
1113
+ * @param {string} [options.sshKeyPath=''] - Private key path used when the target node is remote and the volume is shipped over SSH. Empty falls back to copyDirToNode's default (engine-private/deploy/id_rsa).
1053
1114
  * @memberof UnderpostDeploy
1054
1115
  */
1055
1116
  deployVolume(
@@ -1062,6 +1123,7 @@ EOF`);
1062
1123
  nodeName: '',
1063
1124
  clusterContext: 'kind',
1064
1125
  gitClean: false,
1126
+ sshKeyPath: '',
1065
1127
  },
1066
1128
  ) {
1067
1129
  if (!volume.claimName) {
@@ -1076,16 +1138,48 @@ EOF`);
1076
1138
  if (options.gitClean && volume.volumeMountPath) {
1077
1139
  Underpost.repo.clean({ paths: [volume.volumeMountPath] });
1078
1140
  }
1141
+ // The node that physically receives the volume data. hostPath volumes are
1142
+ // node-local, so the data must land on the node where the pod will run, and
1143
+ // the PV is pinned there (nodeAffinity) so the scheduler co-locates the pod
1144
+ // with its volume — never mounting an empty DirectoryOrCreate on another node.
1145
+ let dataNode;
1079
1146
  if (clusterContext === 'kind') {
1080
1147
  const kindNode = options.nodeName || 'kind-worker';
1148
+ dataNode = kindNode;
1081
1149
  shellExec(`docker exec -i ${kindNode} bash -c "mkdir -p ${rootVolumeHostPath}"`);
1082
1150
  shellExec(`tar -C ${volume.volumeMountPath} -c . | docker cp - ${kindNode}:${rootVolumeHostPath}`);
1083
1151
  shellExec(
1084
1152
  `docker exec -i ${kindNode} bash -c "chown -R 1000:1000 ${rootVolumeHostPath}; chmod -R 755 ${rootVolumeHostPath}"`,
1085
1153
  );
1086
1154
  } else {
1087
- if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
1088
- fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
1155
+ const localHost = os.hostname();
1156
+ dataNode = options.nodeName || localHost;
1157
+ if (dataNode === localHost) {
1158
+ // Target node is the control plane / current host: write directly.
1159
+ if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
1160
+ fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
1161
+ } else {
1162
+ // Target node is remote: fs.copySync would only write the control-plane
1163
+ // filesystem, leaving the real node's hostPath empty. Ship the folder to
1164
+ // the node over SSH so the data exists where the pod is pinned.
1165
+ const nodeHost =
1166
+ shellExec(
1167
+ `kubectl get node ${dataNode} -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'`,
1168
+ { stdout: true, silent: true, silentOnError: true },
1169
+ ).trim() || dataNode;
1170
+ logger.info('Shipping volume to remote node over SSH', {
1171
+ node: dataNode,
1172
+ host: nodeHost,
1173
+ src: volume.volumeMountPath,
1174
+ dest: rootVolumeHostPath,
1175
+ });
1176
+ Underpost.ssh.copyDirToNode({
1177
+ host: nodeHost,
1178
+ localDir: volume.volumeMountPath,
1179
+ remoteDir: rootVolumeHostPath,
1180
+ ...(options.sshKeyPath ? { keyPath: options.sshKeyPath } : {}),
1181
+ });
1182
+ }
1089
1183
  }
1090
1184
  shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
1091
1185
  shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
@@ -1094,6 +1188,7 @@ ${Underpost.deploy.persistentVolumeFactory({
1094
1188
  hostPath: rootVolumeHostPath,
1095
1189
  pvcId,
1096
1190
  namespace,
1191
+ nodeName: dataNode,
1097
1192
  })}
1098
1193
  EOF
1099
1194
  `);
@@ -1182,11 +1277,28 @@ ${secret ? ` readOnly: true\n` : ''}`;
1182
1277
  * @param {string} options.hostPath - Host path for the persistent volume.
1183
1278
  * @param {string} options.pvcId - Persistent volume claim ID.
1184
1279
  * @param {string} [options.namespace='default'] - Kubernetes namespace for the PVC claimRef.
1280
+ * @param {string} [options.nodeName=''] - Node name to which the persistent volume is pinned (optional).
1185
1281
  * @returns {string} - YAML configuration for the persistent volume and claim.
1186
1282
  * @memberof UnderpostDeploy
1187
1283
  */
1188
- persistentVolumeFactory({ hostPath, pvcId, namespace = 'default' }) {
1284
+ persistentVolumeFactory({ hostPath, pvcId, namespace = 'default', nodeName = '' }) {
1189
1285
  const pvId = pvcId.replace(/^pvc-/, 'pv-');
1286
+ // hostPath volumes are node-local: deployVolume writes the content to the
1287
+ // filesystem of a single node. Without nodeAffinity the scheduler can place
1288
+ // the pod on a different node and mount an empty DirectoryOrCreate hostPath
1289
+ // (missing the materialized assets). Pin the PV to the node that holds the
1290
+ // data so the pod is always co-located with its volume.
1291
+ const nodeAffinity = nodeName
1292
+ ? `
1293
+ nodeAffinity:
1294
+ required:
1295
+ nodeSelectorTerms:
1296
+ - matchExpressions:
1297
+ - key: kubernetes.io/hostname
1298
+ operator: In
1299
+ values:
1300
+ - ${nodeName}`
1301
+ : '';
1190
1302
  return `apiVersion: v1
1191
1303
  kind: PersistentVolume
1192
1304
  metadata:
@@ -1197,7 +1309,7 @@ spec:
1197
1309
  accessModes:
1198
1310
  - ReadWriteOnce
1199
1311
  persistentVolumeReclaimPolicy: Retain
1200
- storageClassName: manual
1312
+ storageClassName: manual${nodeAffinity}
1201
1313
  claimRef:
1202
1314
  apiVersion: v1
1203
1315
  kind: PersistentVolumeClaim
@@ -77,14 +77,42 @@ class UnderpostDockerCompose {
77
77
  return nodePath.isAbsolute(relPath) ? relPath : nodePath.join(getRootDirectory(), relPath);
78
78
  }
79
79
 
80
+ /**
81
+ * Resolves the canonical directory for a custom docker-compose workflow,
82
+ * keyed by `--deploy-id` + `--docker-compose-id`:
83
+ * `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>`.
84
+ * This directory ships its own `docker-compose.yml`, `compose.env`, and
85
+ * `nginx.conf` (used as-is, never generated). Returns null when
86
+ * `--docker-compose-id` is not set (the default, self-generating workflow).
87
+ * @param {object} options - CLI options.
88
+ * @returns {string|null} Repo-root-relative canonical dir, or null.
89
+ * @memberof UnderpostDockerCompose
90
+ */
91
+ static composeIdBase(options = {}) {
92
+ if (!options.dockerComposeId) return null;
93
+ const deployId = options.deployId || DEFAULT_DEPLOY_ID;
94
+ return `engine-private/conf/${deployId}/docker-compose/${options.dockerComposeId}`;
95
+ }
96
+
80
97
  /**
81
98
  * Builds the base `docker compose` invocation with explicit file and env-file,
82
99
  * so behavior is independent of the caller's working directory.
100
+ *
101
+ * Custom workflow (`--docker-compose-id`): the compose file, env-file, and
102
+ * bind-mounted config (nginx.conf, mongodb/) all live in the canonical dir, so
103
+ * compose runs with `--project-directory` pinned there and no app-override.
83
104
  * @param {object} options - CLI options.
84
105
  * @returns {string} The base command string (without a subcommand).
85
106
  * @memberof UnderpostDockerCompose
86
107
  */
87
108
  static baseCmd(options = {}) {
109
+ const base = UnderpostDockerCompose.composeIdBase(options);
110
+ if (base) {
111
+ const projectDir = UnderpostDockerCompose.resolve(base);
112
+ const composeFile = UnderpostDockerCompose.resolve(options.composeFile || `${base}/docker-compose.yml`);
113
+ const envFile = UnderpostDockerCompose.resolve(options.envFile || `${base}/compose.env`);
114
+ return `docker compose --project-directory ${projectDir} --env-file ${envFile} -f ${composeFile}`;
115
+ }
88
116
  const composeFile = UnderpostDockerCompose.resolve(options.composeFile || 'docker-compose.yml');
89
117
  const envFile = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env');
90
118
  const overrideFile = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml');
@@ -163,7 +191,7 @@ services:
163
191
  MONGO_IMAGE=mongo:latest
164
192
  VALKEY_IMAGE=valkey/valkey:latest
165
193
  APP_IMAGE=underpost/underpost-engine
166
- APP_TAG=v3.2.22
194
+ APP_TAG=v3.2.70
167
195
  PROXY_IMAGE=nginx:stable-alpine
168
196
  PROMETHEUS_IMAGE=prom/prometheus:latest
169
197
  GRAFANA_IMAGE=grafana/grafana:latest
@@ -343,6 +371,20 @@ datasources:
343
371
  * @memberof UnderpostDockerCompose
344
372
  */
345
373
  static generate(options = {}) {
374
+ // Custom workflow: docker-compose.yml, compose.env, and nginx.conf are
375
+ // pre-authored in the canonical dir and used as-is — do NOT generate them.
376
+ // Only (re)write the required MongoDB entrypoint (replica-set bootstrap),
377
+ // the single generated infra artifact, into <canonical>/mongodb so the
378
+ // stack stays self-contained under `--project-directory`.
379
+ const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
380
+ if (composeIdBase) {
381
+ const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
382
+ fs.mkdirpSync(nodePath.dirname(mongoEntrypointPath));
383
+ fs.writeFileSync(mongoEntrypointPath, UnderpostDockerCompose.mongoEntrypointContent(), { mode: 0o755 });
384
+ logger.info('mongodb entrypoint written (custom workflow)', { path: mongoEntrypointPath });
385
+ return;
386
+ }
387
+
346
388
  Nginx.removeRouter();
347
389
  for (const { host, routes } of PROXY_HOSTS) Nginx.createApp({ host, routes });
348
390
  Nginx.createDefaultServer(DEFAULT_UPSTREAM);
@@ -449,6 +491,20 @@ datasources:
449
491
  silentOnError: true,
450
492
  });
451
493
 
494
+ // Custom workflow: the compose file, compose.env, and nginx.conf are
495
+ // hand-authored source — never prune them. Only drop the one generated
496
+ // artifact (the mongo entrypoint), regenerated on the next --up/--generate.
497
+ const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
498
+ if (composeIdBase) {
499
+ const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
500
+ if (fs.existsSync(mongoEntrypointPath)) {
501
+ fs.removeSync(mongoEntrypointPath);
502
+ logger.info('removed generated artifact', { path: mongoEntrypointPath });
503
+ }
504
+ logger.info('Docker Compose reset complete. Run `--up` to recreate the stack.');
505
+ return;
506
+ }
507
+
452
508
  // Phase 2: prune generated artifacts (regenerated on the next --generate/--up).
453
509
  const artifacts = options.force
454
510
  ? [...GENERATED_ARTIFACTS, options.envFile || 'docker/compose.env']
@@ -489,6 +545,7 @@ datasources:
489
545
  * @param {boolean} [options.shell] - Open an interactive shell in `target` (default: app).
490
546
  * @param {string} [options.exec] - General-purpose passthrough docker compose subcommand.
491
547
  * @param {string} [options.deployId] - Deployment to run as the app (default: dd-default). `dd-default` self-bootstraps a fresh engine; any other id runs the standard `underpost start` command.
548
+ * @param {string} [options.dockerComposeId] - Custom-workflow selector. When set, use the canonical stack at `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>/` (docker-compose.yml + compose.env + nginx.conf, used as-is), skipping nginx/env/app-override generation. Used by the Cyberia MMO ecosystem (`--deploy-id dd-cyberia --docker-compose-id cyberia`).
492
549
  * @param {string} [options.env] - Deployment environment for non-default deploy ids (default: development).
493
550
  * @param {string} [options.composeFile] - Override compose file path.
494
551
  * @param {string} [options.envFile] - Override env-file path.
package/src/cli/env.js CHANGED
@@ -143,12 +143,21 @@ class UnderpostRootEnv {
143
143
  /**
144
144
  * @method clean
145
145
  * @description Cleans the underpost root environment by removing the environment file.
146
+ * @param {object} options - Options for cleaning the environment.
147
+ * @param {Array<string>} [options.keepKeys=[]] - List of keys to keep in the environment file. If provided, only these keys will be retained.
146
148
  * @memberof UnderpostEnv
147
149
  */
148
- clean() {
150
+ clean(options = { keepKeys: [] }) {
151
+ const { keepKeys } = options;
149
152
  const exeRootPath = `${getNpmRootPath()}/underpost`;
150
153
  const envPath = `${exeRootPath}/.env`;
151
- fs.removeSync(envPath);
154
+ if (keepKeys && keepKeys.length > 0 && fs.existsSync(envPath)) {
155
+ const env = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
156
+ const filteredEnv = Object.fromEntries(Object.entries(env).filter(([key]) => keepKeys.includes(key)));
157
+ writeEnv(envPath, filteredEnv);
158
+ } else {
159
+ fs.removeSync(envPath);
160
+ }
152
161
  },
153
162
  /**
154
163
  * @method isInsideContainer
package/src/cli/fs.js CHANGED
@@ -64,6 +64,31 @@ class UnderpostFileStorage {
64
64
  writeStorageConf(storage, storageConf) {
65
65
  if (storage) fs.writeFileSync(storageConf, JSON.stringify(storage, null, 4), 'utf8');
66
66
  },
67
+ /**
68
+ * @method gitTrack
69
+ * @description Optional, non-fatal Git tracking layer. Any Git error is logged and swallowed
70
+ * so it can never interrupt or roll back the canonical `storage.*.json` workflow.
71
+ * @param {string} gitPath - The working directory to stage/commit.
72
+ * @param {object} [options] - Tracking options.
73
+ * @param {boolean} [options.init=false] - If true, initialize a local repo before staging.
74
+ * @param {string} [options.message=''] - Explicit commit message; when omitted, `underpost cmt` is used.
75
+ * @memberof UnderpostFileStorage
76
+ */
77
+ gitTrack(gitPath, options = { init: false, message: '' }) {
78
+ try {
79
+ if (options.init === true) Underpost.repo.initLocalRepo({ path: gitPath });
80
+ shellExec(`cd ${gitPath} && git add .`, { silentOnError: true, silent: true, disableLog: true });
81
+ if (options.message)
82
+ shellExec(`cd ${gitPath} && git commit -m "${options.message}"`, {
83
+ silentOnError: true,
84
+ silent: true,
85
+ disableLog: true,
86
+ });
87
+ else shellExec(`underpost cmt ${gitPath} feat`, { silentOnError: true, silent: true, disableLog: true });
88
+ } catch (error) {
89
+ logger.warn('git tracking skipped (non-fatal)', { gitPath, error: error?.message });
90
+ }
91
+ },
67
92
  /**
68
93
  * @method recursiveCallback
69
94
  * @description Recursively processes files and directories based on the provided options.
@@ -125,6 +150,7 @@ class UnderpostFileStorage {
125
150
 
126
151
  if (hasPathFilter && options.force === true && fs.existsSync(basePath)) fs.removeSync(basePath);
127
152
 
153
+ // Storage is canonical: persist the removal before any (optional) git tracking runs.
128
154
  Underpost.fs.writeStorageConf(storage, storageConf);
129
155
 
130
156
  if (associatedPaths.length === 0)
@@ -136,13 +162,8 @@ class UnderpostFileStorage {
136
162
  });
137
163
 
138
164
  if (options.git === true) {
139
- const gitPath = hasPathFilter ? basePath : '.';
140
- shellExec(`cd ${gitPath} && git add .`);
141
- shellExec(`underpost cmt ${gitPath} feat`, {
142
- silentOnError: true,
143
- silent: true,
144
- disableLog: true,
145
- });
165
+ const gitPath = !hasPathFilter ? '.' : isSingleFile ? parentDir : basePath;
166
+ Underpost.fs.gitTrack(gitPath);
146
167
  }
147
168
 
148
169
  return;
@@ -151,7 +172,16 @@ class UnderpostFileStorage {
151
172
  // For single files, run getDeleteFiles against the parent directory to avoid
152
173
  // trying to `cd` into a file.
153
174
  const gitContextPath = isSingleFile ? parentDir : path;
154
- const deleteFiles = options.pull === true ? [] : Underpost.repo.getDeleteFiles(gitContextPath);
175
+ // Detecting locally-deleted files is a best-effort enhancement backed by git; if the path is
176
+ // not a repo (or git is unavailable) it must not block the canonical storage workflow.
177
+ let deleteFiles = [];
178
+ if (options.pull !== true) {
179
+ try {
180
+ deleteFiles = Underpost.repo.getDeleteFiles(gitContextPath);
181
+ } catch (error) {
182
+ logger.warn('delete detection skipped (git unavailable)', { path: gitContextPath, error: error?.message });
183
+ }
184
+ }
155
185
 
156
186
  // When processing a single file, only consider it for deletion
157
187
  for (const relativePath of deleteFiles) {
@@ -172,12 +202,7 @@ class UnderpostFileStorage {
172
202
  if (pullSkipCount > 0) logger.warn(`Pull skipped ${pullSkipCount} files that already exist`);
173
203
  // Only run git init/commit when the caller explicitly requests git tracking (--git flag).
174
204
  // For bundle pulls into ./build the git step is unwanted and would error on a non-repo path.
175
- if (options.git === true) {
176
- Underpost.repo.initLocalRepo({ path: gitContextPath });
177
- shellExec(`cd ${gitContextPath} && git add . && git commit -m "Base pull state"`, {
178
- silentOnError: true,
179
- });
180
- }
205
+ if (options.git === true) Underpost.fs.gitTrack(gitContextPath, { init: true, message: 'Base pull state' });
181
206
  } else {
182
207
  let files;
183
208
  if (isSingleFile) {
@@ -200,15 +225,9 @@ class UnderpostFileStorage {
200
225
  } else logger.warn('File already exists', _path);
201
226
  }
202
227
  }
228
+ // Storage is canonical and always persisted; git is an optional layer on top.
203
229
  Underpost.fs.writeStorageConf(storage, storageConf);
204
- if (options.git === true) {
205
- shellExec(`cd ${gitContextPath} && git add .`);
206
- shellExec(`underpost cmt ${gitContextPath} feat`, {
207
- silentOnError: true,
208
- silent: true,
209
- disableLog: true,
210
- });
211
- }
230
+ if (options.git === true) Underpost.fs.gitTrack(gitContextPath);
212
231
  },
213
232
  /**
214
233
  * @method callback
@@ -230,10 +249,12 @@ class UnderpostFileStorage {
230
249
  path,
231
250
  options = { rm: false, recursive: false, deployId: '', force: false, pull: false, git: false, omitUnzip: false },
232
251
  ) {
233
- if (options.recursive === true || options.git === true)
252
+ // rm always routes through recursiveCallback so storage.*.json is updated regardless of
253
+ // --recursive/--git. The bare `delete` primitive only removes the remote asset and would
254
+ // otherwise leave the tracked storage key orphaned.
255
+ if (options.recursive === true || options.git === true || options.rm === true)
234
256
  return await Underpost.fs.recursiveCallback(path, options);
235
257
  if (options.pull === true) return await Underpost.fs.pull(path, options);
236
- if (options.rm === true) return await Underpost.fs.delete(path, options);
237
258
  return await Underpost.fs.upload(path, options);
238
259
  },
239
260
  /**